feat: add admin dashboard and core API routes with Docker configuration
This commit is contained in:
@@ -1,13 +1,11 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
|
admin/node_modules
|
||||||
|
admin/.next
|
||||||
.git
|
.git
|
||||||
.gitea
|
.gitignore
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
.env.development.local
|
*.log
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
dist
|
dist
|
||||||
build
|
dist-ssr
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,7 +13,8 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22.23.4-alpine
|
||||||
RUN apk add --no-cache libc6-compat openssl
|
RUN apk update && apk upgrade --no-cache && apk add --no-cache libc6-compat openssl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PATH /app/node_modules/.bin:$PATH
|
ENV PATH /app/node_modules/.bin:$PATH
|
||||||
|
|
||||||
@@ -21,4 +21,4 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \
|
|||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
# On lance directement via npm
|
# On lance directement via npm
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22.23.4-alpine
|
||||||
RUN apk add --no-cache libc6-compat openssl
|
RUN apk update && apk upgrade --no-cache && apk add --no-cache libc6-compat openssl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PATH /app/node_modules/.bin:$PATH
|
ENV PATH /app/node_modules/.bin:$PATH
|
||||||
|
|
||||||
# Copie et installation
|
# Copie et installation
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -21,5 +21,5 @@ USER nextjs
|
|||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
|
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)
|
# On lance les migrations sans perte de données puis le serveur
|
||||||
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"]
|
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 type { NextConfig } from "next";
|
||||||
import path from "path";
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// output: 'standalone',
|
// output: 'standalone',
|
||||||
allowedDevOrigins: ['10.0.2.2'],
|
allowedDevOrigins: ['10.0.2.2'],
|
||||||
@@ -10,6 +10,33 @@ const nextConfig: NextConfig = {
|
|||||||
ignoreBuildErrors: true,
|
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
|
// 3. (Optionnel) Pour faciliter le chargement des images si tu en as
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -21,4 +48,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
[phases.setup]
|
[phases.setup]
|
||||||
# Ce hash force Nixpacks à utiliser la version contenant Node 22.13.1
|
|
||||||
nixpkgsArchive = "51ad838b03a05b1de6f9f2a0fffecee64a9788ee"
|
|
||||||
nixPkgs = ["nodejs_22", "openssl"]
|
nixPkgs = ["nodejs_22", "openssl"]
|
||||||
|
|
||||||
[phases.install]
|
[phases.install]
|
||||||
@@ -10,4 +8,4 @@ cmds = ["npm ci"]
|
|||||||
cmds = ["npx prisma generate", "npm run build"]
|
cmds = ["npx prisma generate", "npm run build"]
|
||||||
|
|
||||||
[start]
|
[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",
|
"name": "admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.23.4"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "prisma generate --schema=../prisma/schema.prisma && next build",
|
"build": "prisma generate --schema=../prisma/schema.prisma && next build",
|
||||||
@@ -10,13 +13,13 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@prisma/client": "6.19.3",
|
||||||
"afrohub": "file:..",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.3",
|
"next": "^16.4.3",
|
||||||
|
"nodemailer": "^10.3.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -29,14 +32,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@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/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-simple-maps": "^3.0.6",
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "^16.4.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
import { ContentStatus } from "@prisma/client";
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
import { requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function createBlogPost(data: {
|
export async function createBlogPost(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,6 +23,7 @@ export async function createBlogPost(data: {
|
|||||||
sources?: any;
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
const post = await prisma.blogPost.create({
|
const post = await prisma.blogPost.create({
|
||||||
@@ -59,6 +61,7 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
sources?: any;
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
const post = await prisma.blogPost.update({
|
const post = await prisma.blogPost.update({
|
||||||
@@ -80,6 +83,7 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
|
|
||||||
export async function deleteBlogPost(id: string) {
|
export async function deleteBlogPost(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
await prisma.blogPost.delete({
|
await prisma.blogPost.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
@@ -94,6 +98,7 @@ export async function deleteBlogPost(id: string) {
|
|||||||
|
|
||||||
export async function getBlogPosts() {
|
export async function getBlogPosts() {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
return await prisma.blogPost.findMany({
|
return await prisma.blogPost.findMany({
|
||||||
orderBy: { date: 'desc' }
|
orderBy: { date: 'desc' }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { ContentStatus } from "@prisma/client";
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
import { requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function approveBlogPost(id: string) {
|
export async function approveBlogPost(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
await prisma.blogPost.update({
|
await prisma.blogPost.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: ContentStatus.PUBLISHED }
|
data: { status: ContentStatus.PUBLISHED }
|
||||||
@@ -21,6 +23,7 @@ export async function approveBlogPost(id: string) {
|
|||||||
|
|
||||||
export async function approveEvent(id: string) {
|
export async function approveEvent(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
await prisma.event.update({
|
await prisma.event.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: ContentStatus.PUBLISHED }
|
data: { status: ContentStatus.PUBLISHED }
|
||||||
@@ -36,7 +39,7 @@ export async function approveEvent(id: string) {
|
|||||||
|
|
||||||
export async function rejectBlogPost(id: string) {
|
export async function rejectBlogPost(id: string) {
|
||||||
try {
|
try {
|
||||||
// We could delete or archive it. Let's archive it.
|
await requireTabPermission("actualites");
|
||||||
await prisma.blogPost.update({
|
await prisma.blogPost.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: ContentStatus.ARCHIVED }
|
data: { status: ContentStatus.ARCHIVED }
|
||||||
@@ -51,6 +54,7 @@ export async function rejectBlogPost(id: string) {
|
|||||||
|
|
||||||
export async function rejectEvent(id: string) {
|
export async function rejectEvent(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
await prisma.event.update({
|
await prisma.event.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: ContentStatus.ARCHIVED }
|
data: { status: ContentStatus.ARCHIVED }
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdminSession, requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function toggleVerification(id: string, currentStatus: boolean) {
|
export async function toggleVerification(id: string, currentStatus: boolean) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { verified: !currentStatus },
|
data: { verified: !currentStatus },
|
||||||
@@ -24,6 +26,7 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
keyMetric: string;
|
keyMetric: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
// 1. Réinitialiser les autres pour n'avoir qu'une seule Tête d'affiche Afroshine active
|
// 1. Réinitialiser les autres pour n'avoir qu'une seule Tête d'affiche Afroshine active
|
||||||
await prisma.business.updateMany({
|
await prisma.business.updateMany({
|
||||||
where: { isFeatured: true },
|
where: { isFeatured: true },
|
||||||
@@ -53,6 +56,7 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
|
|
||||||
export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isHomeFeatured: !currentStatus },
|
data: { isHomeFeatured: !currentStatus },
|
||||||
@@ -68,6 +72,7 @@ export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
|||||||
|
|
||||||
export async function removeFeaturedBusiness(id: string) {
|
export async function removeFeaturedBusiness(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isFeatured: false },
|
data: { isFeatured: false },
|
||||||
@@ -83,6 +88,7 @@ export async function removeFeaturedBusiness(id: string) {
|
|||||||
|
|
||||||
export async function getPendingVerificationCount() {
|
export async function getPendingVerificationCount() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.business.count({
|
return await prisma.business.count({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -98,11 +104,13 @@ export async function getPendingVerificationCount() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBusinessSeo(id: string, data: {
|
export async function updateBusinessSeo(id: string, data: {
|
||||||
metaTitle: string;
|
metaTitle: string;
|
||||||
metaDescription: string;
|
metaDescription: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdminSession, requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function getCategories() {
|
export async function getCategories() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.businessCategory.findMany({
|
return await prisma.businessCategory.findMany({
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { name: 'asc' }
|
||||||
});
|
});
|
||||||
@@ -16,6 +18,7 @@ export async function getCategories() {
|
|||||||
|
|
||||||
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
|
export async function createCategory(data: { name: string; slug: string; icon?: string; suggestionId?: string }) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("taxonomies");
|
||||||
const { suggestionId, ...categoryData } = data;
|
const { suggestionId, ...categoryData } = data;
|
||||||
|
|
||||||
const category = await prisma.businessCategory.create({
|
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 }) {
|
export async function updateCategory(id: string, data: { name: string; slug: string; icon?: string; isActive: boolean }) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("taxonomies");
|
||||||
// 1. Récupérer l'ancien nom pour la migration
|
// 1. Récupérer l'ancien nom pour la migration
|
||||||
const oldCategory = await prisma.businessCategory.findUnique({
|
const oldCategory = await prisma.businessCategory.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -77,6 +81,7 @@ export async function updateCategory(id: string, data: { name: string; slug: str
|
|||||||
|
|
||||||
export async function deleteCategory(id: string) {
|
export async function deleteCategory(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("taxonomies");
|
||||||
await prisma.businessCategory.delete({
|
await prisma.businessCategory.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
@@ -91,6 +96,7 @@ export async function deleteCategory(id: string) {
|
|||||||
|
|
||||||
export async function getSuggestedCategories() {
|
export async function getSuggestedCategories() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.business.findMany({
|
return await prisma.business.findMany({
|
||||||
where: {
|
where: {
|
||||||
suggestedCategory: { not: null },
|
suggestedCategory: { not: null },
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function deleteComment(id: string) {
|
export async function deleteComment(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("comments");
|
||||||
await prisma.comment.delete({
|
await prisma.comment.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||||
|
|
||||||
export async function getCountries() {
|
export async function getCountries() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.country.findMany({
|
return await prisma.country.findMany({
|
||||||
orderBy: { name: 'asc' }
|
orderBy: { name: 'asc' }
|
||||||
});
|
});
|
||||||
@@ -16,6 +18,7 @@ export async function getCountries() {
|
|||||||
|
|
||||||
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
|
export async function addCountry(data: { name: string, code: string, flag?: string, description?: string }) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('countries');
|
||||||
const country = await prisma.country.create({
|
const country = await prisma.country.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -35,6 +38,7 @@ export async function addCountry(data: { name: string, code: string, flag?: stri
|
|||||||
|
|
||||||
export async function deleteCountry(id: string) {
|
export async function deleteCountry(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('countries');
|
||||||
await prisma.country.delete({
|
await prisma.country.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
@@ -48,6 +52,7 @@ export async function deleteCountry(id: string) {
|
|||||||
|
|
||||||
export async function toggleCountryStatus(id: string, isActive: boolean) {
|
export async function toggleCountryStatus(id: string, isActive: boolean) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('countries');
|
||||||
await prisma.country.update({
|
await prisma.country.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isActive }
|
data: { isActive }
|
||||||
@@ -62,6 +67,7 @@ export async function toggleCountryStatus(id: string, isActive: boolean) {
|
|||||||
|
|
||||||
export async function updateCountryDescription(id: string, description: string) {
|
export async function updateCountryDescription(id: string, description: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('countries');
|
||||||
await prisma.country.update({
|
await prisma.country.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { description }
|
data: { description }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
import { ContentStatus } from "@prisma/client";
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
import { requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function createEvent(data: {
|
export async function createEvent(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,6 +23,7 @@ export async function createEvent(data: {
|
|||||||
coverZoom?: number;
|
coverZoom?: number;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
const event = await prisma.event.create({
|
const event = await prisma.event.create({
|
||||||
@@ -59,6 +61,7 @@ export async function updateEvent(id: string, data: {
|
|||||||
coverZoom?: number;
|
coverZoom?: number;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
const event = await prisma.event.update({
|
const event = await prisma.event.update({
|
||||||
@@ -80,6 +83,7 @@ export async function updateEvent(id: string, data: {
|
|||||||
|
|
||||||
export async function deleteEvent(id: string) {
|
export async function deleteEvent(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
await prisma.event.delete({
|
await prisma.event.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { InterviewType, ContentStatus } from "@prisma/client";
|
import { InterviewType, ContentStatus } from "@prisma/client";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function createInterview(data: {
|
export async function createInterview(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,6 +25,7 @@ export async function createInterview(data: {
|
|||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
const interview = await prisma.interview.create({
|
const interview = await prisma.interview.create({
|
||||||
@@ -62,6 +64,7 @@ export async function updateInterview(id: string, data: {
|
|||||||
status?: ContentStatus;
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|
||||||
const interview = await prisma.interview.update({
|
const interview = await prisma.interview.update({
|
||||||
@@ -82,6 +85,7 @@ export async function updateInterview(id: string, data: {
|
|||||||
|
|
||||||
export async function deleteInterview(id: string) {
|
export async function deleteInterview(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("actualites");
|
||||||
await prisma.interview.delete({
|
await prisma.interview.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||||
|
|
||||||
export async function getLegalDocument(type: 'CGU' | 'CGV') {
|
export async function getLegalDocument(type: 'CGU' | 'CGV') {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.legalDocument.findUnique({
|
return await prisma.legalDocument.findUnique({
|
||||||
where: { type }
|
where: { type }
|
||||||
});
|
});
|
||||||
@@ -16,6 +18,7 @@ export async function getLegalDocument(type: 'CGU' | 'CGV') {
|
|||||||
|
|
||||||
export async function updateLegalDocument(type: 'CGU' | 'CGV', title: string, content: string) {
|
export async function updateLegalDocument(type: 'CGU' | 'CGV', title: string, content: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('legal');
|
||||||
const doc = await prisma.legalDocument.upsert({
|
const doc = await prisma.legalDocument.upsert({
|
||||||
where: { type },
|
where: { type },
|
||||||
update: { title, content },
|
update: { title, content },
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireTabPermission } from '@/lib/auth';
|
||||||
|
|
||||||
export async function getReports() {
|
export async function getReports() {
|
||||||
|
await requireTabPermission('moderation');
|
||||||
return await prisma.messageReport.findMany({
|
return await prisma.messageReport.findMany({
|
||||||
include: {
|
include: {
|
||||||
message: {
|
message: {
|
||||||
@@ -40,6 +42,7 @@ export async function getReports() {
|
|||||||
|
|
||||||
export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMISSED') {
|
export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMISSED') {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('moderation');
|
||||||
await prisma.messageReport.update({
|
await prisma.messageReport.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status }
|
data: { status }
|
||||||
@@ -52,6 +55,7 @@ export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMI
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingRatings() {
|
export async function getPendingRatings() {
|
||||||
|
await requireTabPermission('moderation');
|
||||||
return await prisma.rating.findMany({
|
return await prisma.rating.findMany({
|
||||||
where: { status: 'PENDING' },
|
where: { status: 'PENDING' },
|
||||||
include: {
|
include: {
|
||||||
@@ -68,6 +72,7 @@ export async function getPendingRatings() {
|
|||||||
|
|
||||||
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
|
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('moderation');
|
||||||
const updated = await prisma.rating.update({
|
const updated = await prisma.rating.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status }
|
data: { status }
|
||||||
@@ -98,6 +103,7 @@ export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJEC
|
|||||||
|
|
||||||
export async function deleteRating(id: string) {
|
export async function deleteRating(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('moderation');
|
||||||
const rating = await prisma.rating.findUnique({
|
const rating = await prisma.rating.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { businessId: true }
|
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" };
|
return { success: false, error: "Erreur lors de la suppression de l'avis" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { OneShotType } from "@prisma/client";
|
import { OneShotType } from "@prisma/client";
|
||||||
|
import { requireAdminSession, requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function getOneShotPlans() {
|
export async function getOneShotPlans() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.oneShotPlan.findMany({
|
return await prisma.oneShotPlan.findMany({
|
||||||
orderBy: { type: 'asc' }
|
orderBy: { type: 'asc' }
|
||||||
});
|
});
|
||||||
@@ -22,6 +24,7 @@ export async function updateOneShotPlan(type: OneShotType, data: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("plans");
|
||||||
await prisma.oneShotPlan.upsert({
|
await prisma.oneShotPlan.upsert({
|
||||||
where: { type },
|
where: { type },
|
||||||
update: data,
|
update: data,
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||||
|
import { hasTabPermission } from '@/lib/permissions';
|
||||||
|
|
||||||
export async function getPricingPlans() {
|
export async function getPricingPlans() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
return await prisma.pricingPlan.findMany({
|
return await prisma.pricingPlan.findMany({
|
||||||
orderBy: { offerLimit: 'asc' }
|
orderBy: { offerLimit: 'asc' }
|
||||||
});
|
});
|
||||||
@@ -16,6 +19,7 @@ export async function getPricingPlans() {
|
|||||||
|
|
||||||
export async function updatePricingPlanDetail(id: string, data: any) {
|
export async function updatePricingPlanDetail(id: string, data: any) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('plans');
|
||||||
await prisma.pricingPlan.update({
|
await prisma.pricingPlan.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -42,6 +46,14 @@ export async function updatePricingPlanDetail(id: string, data: any) {
|
|||||||
|
|
||||||
export async function updateBusinessPlan(id: string, plan: 'STARTER' | 'BOOSTER' | 'EMPIRE') {
|
export async function updateBusinessPlan(id: string, plan: 'STARTER' | 'BOOSTER' | 'EMPIRE') {
|
||||||
try {
|
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({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { plan }
|
data: { plan }
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||||
|
|
||||||
export async function getSiteSettings() {
|
export async function getSiteSettings() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
const settings = await prisma.siteSetting.findUnique({
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
where: { id: 'singleton' }
|
where: { id: 'singleton' }
|
||||||
});
|
});
|
||||||
@@ -66,6 +68,7 @@ export async function getSiteSettings() {
|
|||||||
|
|
||||||
export async function updateSiteSettings(data: any) {
|
export async function updateSiteSettings(data: any) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('settings');
|
||||||
const settings = await prisma.siteSetting.upsert({
|
const settings = await prisma.siteSetting.upsert({
|
||||||
where: { id: 'singleton' },
|
where: { id: 'singleton' },
|
||||||
update: data,
|
update: data,
|
||||||
@@ -86,5 +89,6 @@ export async function updateSiteSettings(data: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getExternalApiKey() {
|
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 { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireTabPermission } from "@/lib/auth";
|
||||||
|
|
||||||
export async function getSlides() {
|
export async function getSlides() {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("slides");
|
||||||
return await prisma.homeSlide.findMany({
|
return await prisma.homeSlide.findMany({
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
include: { business: true }
|
include: { business: true }
|
||||||
@@ -17,6 +19,7 @@ export async function getSlides() {
|
|||||||
|
|
||||||
export async function createSlide(data: any) {
|
export async function createSlide(data: any) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("slides");
|
||||||
await prisma.homeSlide.create({
|
await prisma.homeSlide.create({
|
||||||
data: {
|
data: {
|
||||||
type: data.type,
|
type: data.type,
|
||||||
@@ -41,6 +44,7 @@ export async function createSlide(data: any) {
|
|||||||
|
|
||||||
export async function updateSlide(id: string, data: any) {
|
export async function updateSlide(id: string, data: any) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("slides");
|
||||||
await prisma.homeSlide.update({
|
await prisma.homeSlide.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -66,6 +70,7 @@ export async function updateSlide(id: string, data: any) {
|
|||||||
|
|
||||||
export async function deleteSlide(id: string) {
|
export async function deleteSlide(id: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("slides");
|
||||||
await prisma.homeSlide.delete({
|
await prisma.homeSlide.delete({
|
||||||
where: { id }
|
where: { id }
|
||||||
});
|
});
|
||||||
@@ -79,6 +84,7 @@ export async function deleteSlide(id: string) {
|
|||||||
|
|
||||||
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("slides");
|
||||||
await prisma.homeSlide.update({
|
await prisma.homeSlide.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isActive: !currentStatus }
|
data: { isActive: !currentStatus }
|
||||||
@@ -93,6 +99,7 @@ export async function toggleSlideStatus(id: string, currentStatus: boolean) {
|
|||||||
|
|
||||||
export async function searchBusinesses(query: string) {
|
export async function searchBusinesses(query: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("slides");
|
||||||
return await prisma.business.findMany({
|
return await prisma.business.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
|
|||||||
@@ -2,20 +2,23 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireTabPermission, ADMIN_SAFE_USER_SELECT } from "@/lib/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspend a user account
|
* Suspend a user account
|
||||||
*/
|
*/
|
||||||
export async function suspendUser(userId: string, reason: string) {
|
export async function suspendUser(userId: string, reason: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
isSuspended: true,
|
isSuspended: true,
|
||||||
suspensionReason: reason,
|
suspensionReason: reason,
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
businesses: true
|
...ADMIN_SAFE_USER_SELECT,
|
||||||
|
businesses: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,14 +50,16 @@ export async function suspendUser(userId: string, reason: string) {
|
|||||||
*/
|
*/
|
||||||
export async function unsuspendUser(userId: string) {
|
export async function unsuspendUser(userId: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
suspensionReason: null,
|
suspensionReason: null,
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
businesses: true
|
...ADMIN_SAFE_USER_SELECT,
|
||||||
|
businesses: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +90,7 @@ export async function unsuspendUser(userId: string) {
|
|||||||
*/
|
*/
|
||||||
export async function suspendBusiness(businessId: string, reason: string) {
|
export async function suspendBusiness(businessId: string, reason: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const business = await prisma.business.update({
|
const business = await prisma.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
data: {
|
data: {
|
||||||
@@ -107,6 +113,7 @@ export async function suspendBusiness(businessId: string, reason: string) {
|
|||||||
*/
|
*/
|
||||||
export async function unsuspendBusiness(businessId: string) {
|
export async function unsuspendBusiness(businessId: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const business = await prisma.business.update({
|
const business = await prisma.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { requireAdminSession, requireTabPermission } from '@/lib/auth';
|
||||||
|
|
||||||
export async function getAllTags() {
|
export async function getAllTags() {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
const [blogPosts, interviews, events] = await Promise.all([
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
prisma.blogPost.findMany({ select: { tags: true } }),
|
prisma.blogPost.findMany({ select: { tags: true } }),
|
||||||
prisma.interview.findMany({ select: { tags: true } }),
|
prisma.interview.findMany({ select: { tags: true } }),
|
||||||
@@ -25,9 +27,7 @@ export async function getAllTags() {
|
|||||||
|
|
||||||
export async function deleteTagGlobally(tagName: string) {
|
export async function deleteTagGlobally(tagName: string) {
|
||||||
try {
|
try {
|
||||||
// This is expensive as we need to update all records
|
await requireTabPermission('taxonomies');
|
||||||
// In a real app, a Tag model would be better
|
|
||||||
|
|
||||||
const [blogPosts, interviews, events] = await Promise.all([
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
||||||
prisma.interview.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) {
|
export async function renameTagGlobally(oldName: string, newName: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('taxonomies');
|
||||||
const cleanNewName = newName.trim().toLowerCase();
|
const cleanNewName = newName.trim().toLowerCase();
|
||||||
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
|
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) {
|
export async function getTagUsage(tagName: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission('taxonomies');
|
||||||
const [blogPosts, interviews, events] = await Promise.all([
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
prisma.blogPost.findMany({
|
prisma.blogPost.findMany({
|
||||||
where: { tags: { has: tagName } },
|
where: { tags: { has: tagName } },
|
||||||
|
|||||||
@@ -2,22 +2,85 @@
|
|||||||
|
|
||||||
import { writeFile, mkdir } from 'fs/promises';
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
import { join } from 'path';
|
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) {
|
export async function uploadImage(formData: FormData) {
|
||||||
try {
|
try {
|
||||||
|
await requireAdminSession();
|
||||||
|
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return { success: false, error: 'Aucun fichier fourni' };
|
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 bytes = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(bytes);
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
// Create a unique filename
|
// Validate magic bytes & determine extension from whitelist only
|
||||||
let fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
const fileExtension = detectImageExtension(buffer);
|
||||||
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
if (!fileExtension) {
|
||||||
|
return {
|
||||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
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)
|
// Path for the ROOT public/uploads (where the main site will look)
|
||||||
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireTabPermission, ADMIN_SAFE_USER_SELECT } from "@/lib/auth";
|
||||||
|
|
||||||
export async function getClients() {
|
export async function getClients() {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const clients = await prisma.user.findMany({
|
const clients = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: { not: "ADMIN" },
|
role: { not: "ADMIN" },
|
||||||
@@ -12,7 +14,8 @@ export async function getClients() {
|
|||||||
{ businesses: { isActive: false } }
|
{ businesses: { isActive: false } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
include: {
|
select: {
|
||||||
|
...ADMIN_SAFE_USER_SELECT,
|
||||||
businesses: true
|
businesses: true
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -28,6 +31,7 @@ export async function getClients() {
|
|||||||
|
|
||||||
export async function verifyUserEmail(userId: string) {
|
export async function verifyUserEmail(userId: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
@@ -41,10 +45,13 @@ export async function verifyUserEmail(userId: string) {
|
|||||||
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
return { success: false, error: "Erreur lors de la vérification de l'email" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleUserDeletion(userId: string, reason: string, delayDays: number) {
|
export async function scheduleUserDeletion(userId: string, reason: string, delayDays: number) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const user = await prisma.user.findUnique({
|
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é" };
|
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) {
|
export async function reactivateUser(userId: string, reason: string) {
|
||||||
try {
|
try {
|
||||||
|
await requireTabPermission("users");
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
deletionScheduledAt: null,
|
deletionScheduledAt: null,
|
||||||
reactivationReason: reason
|
reactivationReason: reason
|
||||||
}
|
},
|
||||||
|
select: ADMIN_SAFE_USER_SELECT
|
||||||
});
|
});
|
||||||
|
|
||||||
const settings = await prisma.siteSetting.findUnique({
|
const settings = await prisma.siteSetting.findUnique({
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
import { prisma } from "@/lib/prisma";
|
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
|
* Route pour le nettoyage automatique des comptes
|
||||||
* À appeler via une tâche cron (ex: chaque nuit à minuit)
|
* À 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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
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 });
|
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 { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import crypto from 'crypto'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { generateSlug } from '@/lib/utils'
|
import { generateSlug } from '@/lib/utils'
|
||||||
import { revalidatePath } from 'next/cache'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Simple API Key authentication
|
// Constant-time API Key authentication
|
||||||
const apiKey = request.headers.get('x-api-key')
|
const apiKey = request.headers.get('x-api-key')
|
||||||
const expectedApiKey = process.env.EXTERNAL_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 })
|
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey !== expectedApiKey) {
|
if (!verifyApiKey(apiKey, expectedApiKey)) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import crypto from 'crypto'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { generateSlug } from '@/lib/utils'
|
import { generateSlug } from '@/lib/utils'
|
||||||
import { revalidatePath } from 'next/cache'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Simple API Key authentication
|
// Constant-time API Key authentication
|
||||||
const apiKey = request.headers.get('x-api-key')
|
const apiKey = request.headers.get('x-api-key')
|
||||||
const expectedApiKey = process.env.EXTERNAL_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 })
|
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey !== expectedApiKey) {
|
if (!verifyApiKey(apiKey, expectedApiKey)) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
{
|
|
||||||
status: 'ok',
|
|
||||||
service: 'afrohub-admin',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function LoginForm() {
|
|||||||
|
|
||||||
toast.success(`Bienvenue, ${res.user?.name || "Administrateur"} !`);
|
toast.success(`Bienvenue, ${res.user?.name || "Administrateur"} !`);
|
||||||
// Full page redirect ensures middleware & server cookies are freshly synchronized
|
// 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) {
|
} catch (err) {
|
||||||
console.error("Login client error:", err);
|
console.error("Login client error:", err);
|
||||||
setErrorMessage("Une erreur imprévue est survenue. Veuillez réessayer.");
|
setErrorMessage("Une erreur imprévue est survenue. Veuillez réessayer.");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { ADMIN_SAFE_USER_SELECT } from '@/lib/auth';
|
||||||
import { getClients } from "@/app/actions/user";
|
import { getClients } from "@/app/actions/user";
|
||||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
import FeaturedModal from '@/components/FeaturedModal';
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
@@ -17,7 +18,9 @@ async function getBusinesses() {
|
|||||||
isActive: true
|
isActive: true
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
owner: true,
|
owner: {
|
||||||
|
select: ADMIN_SAFE_USER_SELECT
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
createdAt: 'desc',
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ import {
|
|||||||
REMEMBER_ME_DURATION,
|
REMEMBER_ME_DURATION,
|
||||||
type AdminSessionPayload,
|
type AdminSessionPayload,
|
||||||
} from './session';
|
} 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 {
|
export interface AdminLoginResult {
|
||||||
success: boolean;
|
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.
|
* and sets the session cookie upon success with role and permissions.
|
||||||
*/
|
*/
|
||||||
export async function loginAdmin(
|
export async function loginAdmin(
|
||||||
@@ -42,68 +64,12 @@ export async function loginAdmin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let user = null;
|
// Search user by unique email (case-insensitive)
|
||||||
try {
|
const user = await prisma.user.findFirst({
|
||||||
// Search user by email (case-insensitive) or by exact name
|
where: {
|
||||||
user = await prisma.user.findFirst({
|
email: { equals: cleanIdentifier, mode: 'insensitive' },
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
@@ -219,6 +185,17 @@ export async function requireAdminSession(): Promise<AdminSessionPayload> {
|
|||||||
return session;
|
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.
|
* Helper to ensure the current admin has SUPER_ADMIN role.
|
||||||
*/
|
*/
|
||||||
@@ -229,4 +206,3 @@ export async function requireSuperAdminSession(): Promise<AdminSessionPayload> {
|
|||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import nodemailer from 'nodemailer';
|
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({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||||
@@ -15,7 +9,7 @@ const transporter = nodemailer.createTransport({
|
|||||||
pass: process.env.SMTP_PASS,
|
pass: process.env.SMTP_PASS,
|
||||||
},
|
},
|
||||||
tls: {
|
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 {
|
try {
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await transporter.sendMail(mailOptions);
|
||||||
console.log('Email sent: ' + info.response);
|
|
||||||
return info;
|
return info;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending email:', error);
|
console.error('Error sending email:', error);
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
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 }
|
const globalForPrisma = globalThis as unknown as { prisma_admin_native?: PrismaClient }
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient({
|
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient(
|
||||||
datasources: {
|
connectionString
|
||||||
db: {
|
? {
|
||||||
url: connectionString,
|
datasources: {
|
||||||
},
|
db: {
|
||||||
},
|
url: connectionString,
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
|
||||||
|
|
||||||
export default prisma
|
export default prisma
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,20 @@ export interface AdminSessionPayload {
|
|||||||
iat: number;
|
iat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JWT_SECRET =
|
function getJwtSecret(): string {
|
||||||
process.env.ADMIN_JWT_SECRET ||
|
const secret =
|
||||||
process.env.NEXTAUTH_SECRET ||
|
process.env.ADMIN_JWT_SECRET ||
|
||||||
'afrohub_admin_secure_jwt_token_secret_key_2025_9876543210';
|
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 {
|
function base64UrlEncode(str: string): string {
|
||||||
if (typeof Buffer !== 'undefined') {
|
if (typeof Buffer !== 'undefined') {
|
||||||
@@ -61,7 +71,7 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
|||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
return await crypto.subtle.importKey(
|
return await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
enc.encode(JWT_SECRET),
|
enc.encode(getJwtSecret()),
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
false,
|
false,
|
||||||
['sign', 'verify']
|
['sign', 'verify']
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ const PUBLIC_PREFIXES = [
|
|||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname, search } = request.nextUrl;
|
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 (
|
if (
|
||||||
pathname.startsWith('/_next') ||
|
!isServerAction &&
|
||||||
pathname.includes('.') // any file with an extension (e.g. favicon.ico, images, robots.txt)
|
(request.method === 'GET' || request.method === 'HEAD') &&
|
||||||
|
(pathname.startsWith('/_next') || pathname.includes('.'))
|
||||||
) {
|
) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -32,21 +34,24 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
// 3. If accessing /login
|
// 3. If accessing /login
|
||||||
if (isLoginPage) {
|
if (isLoginPage) {
|
||||||
// If already logged in, redirect directly to dashboard
|
// If already logged in and not invoking a login server action, redirect directly to dashboard
|
||||||
if (session) {
|
if (session && !isServerAction) {
|
||||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||||
}
|
}
|
||||||
return NextResponse.next();
|
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));
|
const isPublicPrefix = PUBLIC_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||||
if (isPublicPrefix) {
|
if (isPublicPrefix && !isServerAction) {
|
||||||
return NextResponse.next();
|
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 (!session) {
|
||||||
|
if (isServerAction) {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||||
|
}
|
||||||
const loginUrl = new URL('/login', request.url);
|
const loginUrl = new URL('/login', request.url);
|
||||||
const returnUrl = pathname + (search || '');
|
const returnUrl = pathname + (search || '');
|
||||||
if (pathname !== '/' && pathname !== '') {
|
if (pathname !== '/' && pathname !== '') {
|
||||||
@@ -58,6 +63,9 @@ export async function middleware(request: NextRequest) {
|
|||||||
// 6. Check granular tab permission
|
// 6. Check granular tab permission
|
||||||
const tabKey = getTabForPathname(pathname);
|
const tabKey = getTabForPathname(pathname);
|
||||||
if (tabKey && !hasTabPermission(session.adminRole, session.permissions, tabKey)) {
|
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
|
// If user lacks permission for this tab, redirect back to /dashboard
|
||||||
const dashboardUrl = new URL('/dashboard', request.url);
|
const dashboardUrl = new URL('/dashboard', request.url);
|
||||||
dashboardUrl.searchParams.set('unauthorized', tabKey);
|
dashboardUrl.searchParams.set('unauthorized', tabKey);
|
||||||
|
|||||||
@@ -3,8 +3,23 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
export async function reactivateUserAccount(userId: string, reason: string) {
|
export async function reactivateUserAccount(userId: string, reason: string) {
|
||||||
try {
|
try {
|
||||||
|
if (!userId || typeof reason !== "string" || reason.trim().length === 0) {
|
||||||
|
return { success: false, error: "Paramètres invalides" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedReason = reason.trim().slice(0, 1000);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { id: true, name: true, email: true, deletedAt: true }
|
select: { id: true, name: true, email: true, deletedAt: true }
|
||||||
@@ -23,24 +38,24 @@ export async function reactivateUserAccount(userId: string, reason: string) {
|
|||||||
data: {
|
data: {
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
deletionScheduledAt: null,
|
deletionScheduledAt: null,
|
||||||
reactivationReason: reason
|
reactivationReason: sanitizedReason
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optionnel : Envoyer un mail de confirmation
|
// Optionnel : Envoyer un mail de confirmation
|
||||||
const settings = await prisma.siteSetting.findUnique({ where: { id: "singleton" } });
|
const settings = await prisma.siteSetting.findUnique({ where: { id: "singleton" } });
|
||||||
if (settings?.reactivateAccountTemplate) {
|
if (settings?.reactivateAccountTemplate) {
|
||||||
const { sendEmail } = await import("@/lib/mail");
|
const { sendEmail } = await import("@/lib/mail");
|
||||||
const html = settings.reactivateAccountTemplate
|
const html = settings.reactivateAccountTemplate
|
||||||
.replace(/{name}/g, user.name)
|
.replace(/{name}/g, escapeHtml(user.name || ""))
|
||||||
.replace(/{siteName}/g, settings.siteName)
|
.replace(/{siteName}/g, escapeHtml(settings.siteName || "Afrohub"))
|
||||||
.replace(/{reason}/g, reason);
|
.replace(/{reason}/g, escapeHtml(sanitizedReason));
|
||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: settings.reactivateAccountSubject,
|
subject: settings.reactivateAccountSubject,
|
||||||
html
|
html
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { ContentStatus } from "@prisma/client";
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
import { getAuthUser } from "@/lib/auth-utils";
|
||||||
|
import { sanitizeHTML } from "@/lib/sanitize";
|
||||||
|
import { safeUrl } from "@/lib/utils";
|
||||||
|
|
||||||
export async function createEvent(data: {
|
export async function createEvent(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,20 +17,33 @@ export async function createEvent(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser();
|
||||||
|
if (!authUser) {
|
||||||
|
return { success: false, error: "Authentification requise" };
|
||||||
|
}
|
||||||
|
|
||||||
const slug = data.title
|
const slug = data.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/(^-|-$)/g, '');
|
.replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
let validatedLink: string | null = null;
|
||||||
|
if (data.link && data.link.trim()) {
|
||||||
|
const checked = safeUrl(data.link.trim());
|
||||||
|
if (checked !== '#') {
|
||||||
|
validatedLink = checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const event = await prisma.event.create({
|
const event = await prisma.event.create({
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title.trim().slice(0, 200),
|
||||||
description: data.description,
|
description: sanitizeHTML(data.description || ''),
|
||||||
date: new Date(data.date),
|
date: new Date(data.date),
|
||||||
location: data.location,
|
location: data.location.trim().slice(0, 200),
|
||||||
thumbnailUrl: data.thumbnailUrl,
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
link: data.link || null,
|
link: validatedLink,
|
||||||
tags: data.tags || [],
|
tags: Array.isArray(data.tags) ? data.tags.slice(0, 20) : [],
|
||||||
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||||
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { ContentStatus } from "@prisma/client";
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
import { getAuthUser } from "@/lib/auth-utils";
|
||||||
|
import { sanitizeHTML } from "@/lib/sanitize";
|
||||||
|
|
||||||
export async function createNews(data: {
|
export async function createNews(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,6 +16,11 @@ export async function createNews(data: {
|
|||||||
sources?: any;
|
sources?: any;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser();
|
||||||
|
if (!authUser) {
|
||||||
|
return { success: false, error: "Authentification requise" };
|
||||||
|
}
|
||||||
|
|
||||||
const slug = data.title
|
const slug = data.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
@@ -21,12 +28,12 @@ export async function createNews(data: {
|
|||||||
|
|
||||||
const post = await prisma.blogPost.create({
|
const post = await prisma.blogPost.create({
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title.trim().slice(0, 200),
|
||||||
excerpt: data.excerpt,
|
excerpt: data.excerpt.trim().slice(0, 500),
|
||||||
content: data.content,
|
content: sanitizeHTML(data.content || ''),
|
||||||
author: data.author,
|
author: data.author.trim().slice(0, 100),
|
||||||
imageUrl: data.imageUrl,
|
imageUrl: data.imageUrl,
|
||||||
tags: data.tags || [],
|
tags: Array.isArray(data.tags) ? data.tags.slice(0, 20) : [],
|
||||||
sources: data.sources || [],
|
sources: data.sources || [],
|
||||||
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
status: 'PENDING' as ContentStatus, // Always pending for user submission
|
||||||
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
slug: `${slug}-${Math.random().toString(36).substring(2, 7)}`
|
||||||
|
|||||||
@@ -1,34 +1,104 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils';
|
||||||
|
|
||||||
|
const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||||
|
|
||||||
|
function detectSafeImageMimeType(buffer: Buffer): string | null {
|
||||||
|
if (buffer.length < 12) return null;
|
||||||
|
|
||||||
|
// JPEG: FF D8 FF
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 'image/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 'image/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 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadImage(formData: FormData) {
|
export async function uploadImage(formData: FormData) {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser();
|
||||||
|
if (!authUser) {
|
||||||
|
return { success: false, error: 'Authentification requise' };
|
||||||
|
}
|
||||||
|
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return { success: false, error: 'Aucun fichier fourni' };
|
return { success: false, error: 'Aucun fichier fourni' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_UPLOAD_SIZE_BYTES) {
|
||||||
|
return { success: false, error: 'Le fichier dépasse la taille maximale autorisée (5 Mo)' };
|
||||||
|
}
|
||||||
|
|
||||||
// Convert file to buffer
|
// Convert file to buffer
|
||||||
const bytes = await file.arrayBuffer();
|
const bytes = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(bytes);
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
if (buffer.length > MAX_UPLOAD_SIZE_BYTES) {
|
||||||
|
return { success: false, error: 'Le fichier dépasse la taille maximale autorisée (5 Mo)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive MIME type strictly from binary magic bytes (rejects SVG/HTML/scripts)
|
||||||
|
const mimeType = detectSafeImageMimeType(buffer);
|
||||||
|
if (!mimeType) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Format de fichier non autorisé (seuls JPG, PNG, WebP et GIF sont acceptés)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to Base64
|
// Convert to Base64
|
||||||
const base64 = buffer.toString('base64');
|
const base64 = buffer.toString('base64');
|
||||||
let mimeType = file.type || 'image/jpeg';
|
|
||||||
|
|
||||||
// Normalize JPEG mime types
|
|
||||||
if (mimeType === 'image/jpg' || mimeType === 'image/pjpeg') {
|
|
||||||
mimeType = 'image/jpeg';
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||||
|
|
||||||
// Return the data URL directly (it will be saved in the database)
|
// Return the data URL directly (it will be saved in the database)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: dataUrl
|
url: dataUrl,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during image conversion:', error);
|
console.error('Error during image conversion:', error);
|
||||||
return { success: false, error: 'Erreur lors de la conversion de l\'image' };
|
return { success: false, error: "Erreur lors de la conversion de l'image" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, User, Calendar, Share2 } from 'lucide-react';
|
||||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
|
import { safeUrl } from '../../../lib/utils';
|
||||||
import { prisma } from '../../../lib/prisma';
|
import { prisma } from '../../../lib/prisma';
|
||||||
import BlogShareButtons from '../../../components/BlogShareButtons';
|
import BlogShareButtons from '../../../components/BlogShareButtons';
|
||||||
import LightboxImage from '@/components/LightboxImage';
|
import LightboxImage from '@/components/LightboxImage';
|
||||||
@@ -182,7 +183,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
<li key={index} className="flex items-start gap-3">
|
<li key={index} className="flex items-start gap-3">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-2 shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-2 shrink-0" />
|
||||||
<a
|
<a
|
||||||
href={source.url}
|
href={safeUrl(source.url)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-gray-700 hover:text-brand-600 transition-colors break-all"
|
className="text-gray-700 hover:text-brand-600 transition-colors break-all"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
|
import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
|
||||||
import { sanitizeHTML } from '../../../lib/sanitize';
|
import { sanitizeHTML } from '../../../lib/sanitize';
|
||||||
|
import { safeUrl } from '../../../lib/utils';
|
||||||
import { prisma } from '../../../lib/prisma';
|
import { prisma } from '../../../lib/prisma';
|
||||||
import { InterviewType } from '@prisma/client';
|
import { InterviewType } from '@prisma/client';
|
||||||
import LightboxImage from '@/components/LightboxImage';
|
import LightboxImage from '@/components/LightboxImage';
|
||||||
@@ -193,8 +194,9 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
{isEvent && (event as any).link && (
|
{isEvent && (event as any).link && (
|
||||||
<div className="mt-10 text-center px-4 sm:px-0">
|
<div className="mt-10 text-center px-4 sm:px-0">
|
||||||
<Link
|
<Link
|
||||||
href={(event as any).link}
|
href={safeUrl((event as any).link)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center px-8 py-4 bg-brand-600 text-white rounded-full font-bold shadow-lg hover:bg-brand-700 hover:shadow-xl transition-all"
|
className="inline-flex items-center px-8 py-4 bg-brand-600 text-white rounded-full font-bold shadow-lg hover:bg-brand-700 hover:shadow-xl transition-all"
|
||||||
>
|
>
|
||||||
Participer à l'événement <ArrowRight className="w-5 h-5 ml-2" />
|
Participer à l'événement <ArrowRight className="w-5 h-5 ml-2" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ArrowLeft, MapPin, Globe, Mail, Phone, CheckCircle, Star, Facebook, Lin
|
|||||||
import { Business, OfferType, Rating } from '@/types';
|
import { Business, OfferType, Rating } from '@/types';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useUser } from '@/components/UserProvider';
|
import { useUser } from '@/components/UserProvider';
|
||||||
|
import { safeUrl } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialBusiness: Business;
|
initialBusiness: Business;
|
||||||
@@ -614,22 +615,22 @@ const BusinessDetailClient = ({ initialBusiness }: Props) => {
|
|||||||
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
|
<p className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-3">Réseaux Sociaux</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{business.socialLinks.facebook && (
|
{business.socialLinks.facebook && (
|
||||||
<a href={business.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
|
<a href={safeUrl(business.socialLinks.facebook)} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-all">
|
||||||
<Facebook className="w-5 h-5" />
|
<Facebook className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{business.socialLinks.linkedin && (
|
{business.socialLinks.linkedin && (
|
||||||
<a href={business.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
|
<a href={safeUrl(business.socialLinks.linkedin)} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-blue-700 hover:bg-blue-50 transition-all">
|
||||||
<Linkedin className="w-5 h-5" />
|
<Linkedin className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{business.socialLinks.instagram && (
|
{business.socialLinks.instagram && (
|
||||||
<a href={business.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
|
<a href={safeUrl(business.socialLinks.instagram)} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-pink-600 hover:bg-pink-50 transition-all">
|
||||||
<Instagram className="w-5 h-5" />
|
<Instagram className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{business.socialLinks.twitter && (
|
{business.socialLinks.twitter && (
|
||||||
<a href={business.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
|
<a href={safeUrl(business.socialLinks.twitter)} target="_blank" rel="noopener noreferrer" className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400 hover:text-black hover:bg-gray-100 transition-all">
|
||||||
<Twitter className="w-5 h-5" />
|
<Twitter className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../../lib/prisma'
|
import prisma from '../../../../../lib/prisma'
|
||||||
|
import { isAdmin } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// PATCH /api/admin/reports/[id] — Update report status
|
// PATCH /api/admin/reports/[id] — Update report status
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -7,21 +8,10 @@ export async function PATCH(
|
|||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const { status } = await request.json()
|
const { status } = await request.json()
|
||||||
|
|
||||||
if (!userId) {
|
if (!(await isAdmin(request))) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { role: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user || user.role !== 'ADMIN') {
|
|
||||||
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { isAdmin } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/admin/reports — List all reports for moderation
|
// GET /api/admin/reports — List all reports for moderation
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
if (!(await isAdmin(request))) {
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { role: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user || user.role !== 'ADMIN') {
|
|
||||||
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '../../../../../lib/prisma';
|
import prisma from '../../../../../lib/prisma';
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID Business requis' }, { status: 400 });
|
return NextResponse.json({ error: 'ID Business requis' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify ownership or ADMIN role
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, ownerId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Get total views from AnalyticsEvent
|
// 1. Get total views from AnalyticsEvent
|
||||||
const totalViews = await prisma.analyticsEvent.count({
|
const totalViews = await prisma.analyticsEvent.count({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import { getGeoInfo } from '@/lib/geo';
|
import { getGeoInfo } from '@/lib/geo';
|
||||||
|
|
||||||
@@ -7,14 +8,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { type, path, label, value, metadata } = body;
|
const { type, path, label, value, metadata } = body;
|
||||||
|
|
||||||
if (!type || !path) {
|
if (!type || !path || typeof type !== 'string' || typeof path !== 'string') {
|
||||||
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
|
return NextResponse.json({ error: 'Type et Path requis' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract detailed info from headers
|
// Extract detailed info from headers
|
||||||
const userAgent = request.headers.get('user-agent') || '';
|
const userAgent = (request.headers.get('user-agent') || '').slice(0, 500);
|
||||||
const referrer = request.headers.get('referer') || '';
|
const referrer = (request.headers.get('referer') || '').slice(0, 500);
|
||||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || '127.0.0.1';
|
const rawForwardedIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1';
|
||||||
|
const ip = isIP(rawForwardedIp) !== 0 ? rawForwardedIp : '127.0.0.1';
|
||||||
|
|
||||||
// Basic User-Agent parsing
|
// Basic User-Agent parsing
|
||||||
const isMobile = /mobile/i.test(userAgent);
|
const isMobile = /mobile/i.test(userAgent);
|
||||||
@@ -33,22 +35,22 @@ export async function POST(request: NextRequest) {
|
|||||||
else if (/iphone|ipad/i.test(userAgent)) os = 'iOS';
|
else if (/iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||||
|
|
||||||
// Simple Country detection
|
// Simple Country detection
|
||||||
const { country, city } = await getGeoInfo(ip, request.headers);
|
const { country } = await getGeoInfo(ip, request.headers);
|
||||||
|
|
||||||
const event = await prisma.analyticsEvent.create({
|
const event = await prisma.analyticsEvent.create({
|
||||||
data: {
|
data: {
|
||||||
type,
|
type: type.slice(0, 100),
|
||||||
path,
|
path: path.slice(0, 500),
|
||||||
label: label || null,
|
label: typeof label === 'string' ? label.slice(0, 200) : null,
|
||||||
value: value || null,
|
value: typeof value === 'number' ? value : null,
|
||||||
metadata: metadata || {},
|
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
||||||
ip,
|
ip,
|
||||||
country,
|
country,
|
||||||
browser,
|
browser,
|
||||||
os,
|
os,
|
||||||
device,
|
device,
|
||||||
referrer,
|
referrer,
|
||||||
userId: metadata?.userId || null,
|
userId: typeof metadata?.userId === 'string' ? metadata.userId : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
const isOriginValid = verifyOrigin(req);
|
const isOriginValid = verifyOrigin(req);
|
||||||
@@ -52,13 +61,14 @@ export async function POST(req: NextRequest) {
|
|||||||
where: { id: 'singleton' }
|
where: { id: 'singleton' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const siteName = settings?.siteName || 'Afrohub';
|
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
|
||||||
|
const safeUserName = escapeHtml(user.name || '');
|
||||||
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
|
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
|
||||||
|
|
||||||
let html = settings?.resetPasswordTemplate || `
|
let html = settings?.resetPasswordTemplate || `
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||||
<p>Bonjour ${user.name},</p>
|
<p>Bonjour ${safeUserName},</p>
|
||||||
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||||
@@ -71,7 +81,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// Replace placeholders
|
// Replace placeholders
|
||||||
html = html
|
html = html
|
||||||
.replace(/{name}/g, user.name)
|
.replace(/{name}/g, safeUserName)
|
||||||
.replace(/{resetUrl}/g, resetUrl)
|
.replace(/{resetUrl}/g, resetUrl)
|
||||||
.replace(/{siteName}/g, siteName);
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from '@/lib/prisma';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
import { signUserToken, setUserSessionCookie } from '@/lib/auth-utils';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
@@ -75,11 +76,34 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Sign and set HttpOnly session cookie
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const token = await signUserToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
await setUserSessionCookie(token);
|
||||||
|
|
||||||
|
// Return only safe user fields (never leak password, resetToken, verificationToken)
|
||||||
|
const safeUser = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
avatar: user.avatar,
|
||||||
|
bio: user.bio,
|
||||||
|
location: user.location,
|
||||||
|
phone: user.phone,
|
||||||
|
countryId: user.countryId,
|
||||||
|
isSuspended: user.isSuspended,
|
||||||
|
suspensionReason: user.suspensionReason,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Connexion réussie', user: userWithoutPassword },
|
{ message: 'Connexion réussie', user: safeUser },
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
13
app/api/auth/logout/route.ts
Normal file
13
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { clearUserSessionCookie } from '@/lib/auth-utils';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
await clearUserSessionCookie();
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
return NextResponse.json({ error: 'Erreur lors de la déconnexion' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,15 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
const isOriginValid = verifyOrigin(request);
|
const isOriginValid = verifyOrigin(request);
|
||||||
@@ -19,7 +28,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rawBody = await request.json();
|
const rawBody = await request.json();
|
||||||
const name = rawBody.name;
|
const name = typeof rawBody.name === 'string' ? rawBody.name.trim() : '';
|
||||||
const email = rawBody.email?.toLowerCase().trim();
|
const email = rawBody.email?.toLowerCase().trim();
|
||||||
const password = rawBody.password;
|
const password = rawBody.password;
|
||||||
|
|
||||||
@@ -31,9 +40,9 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
|
{ error: 'Le mot de passe doit contenir au moins 8 caractères' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,14 +83,16 @@ export async function POST(request: NextRequest) {
|
|||||||
where: { id: 'singleton' }
|
where: { id: 'singleton' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const siteName = settings?.siteName || 'Afrohub';
|
const safeName = escapeHtml(name);
|
||||||
|
const safeEmail = escapeHtml(email);
|
||||||
|
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
|
||||||
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
||||||
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
||||||
|
|
||||||
let html = settings?.verifyEmailTemplate || `
|
let html = settings?.verifyEmailTemplate || `
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||||
<p>Bonjour ${name},</p>
|
<p>Bonjour ${safeName},</p>
|
||||||
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||||
@@ -89,9 +100,9 @@ export async function POST(request: NextRequest) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Replace placeholders
|
// Replace placeholders with HTML-escaped values
|
||||||
html = html
|
html = html
|
||||||
.replace(/{name}/g, name)
|
.replace(/{name}/g, safeName)
|
||||||
.replace(/{verifyUrl}/g, verifyUrl)
|
.replace(/{verifyUrl}/g, verifyUrl)
|
||||||
.replace(/{siteName}/g, siteName);
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
@@ -107,7 +118,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Send admin notification email
|
// Send admin notification email
|
||||||
try {
|
try {
|
||||||
const adminEmail = settings?.contactEmail || 'support@afrohub.com';
|
const adminEmail = settings?.contactEmail || 'support@afrohub.com';
|
||||||
const adminSubject = `[Admin] Nouvelle inscription sur ${siteName} : ${name}`;
|
const adminSubject = `[Admin] Nouvelle inscription sur ${settings?.siteName || 'Afrohub'} : ${name}`;
|
||||||
const adminHtml = `
|
const adminHtml = `
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
<h2 style="color: #4f46e5; text-align: center;">Notification Admin - Inscription</h2>
|
<h2 style="color: #4f46e5; text-align: center;">Notification Admin - Inscription</h2>
|
||||||
@@ -116,11 +127,11 @@ export async function POST(request: NextRequest) {
|
|||||||
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
|
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold; width: 35%;">Nom complet :</td>
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold; width: 35%;">Nom complet :</td>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${name}</td>
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${safeName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Adresse email :</td>
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Adresse email :</td>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${email}">${email}</a></td>
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${safeEmail}">${safeEmail}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Date d'inscription :</td>
|
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Date d'inscription :</td>
|
||||||
@@ -149,11 +160,25 @@ export async function POST(request: NextRequest) {
|
|||||||
console.error('Failed to send registration email(s):', mailError);
|
console.error('Failed to send registration email(s):', mailError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Omit password from response
|
// Return only safe user fields (never leak password, verificationToken, resetToken)
|
||||||
const { password: _, ...userWithoutPassword } = user;
|
const safeUser = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
avatar: user.avatar,
|
||||||
|
bio: user.bio,
|
||||||
|
location: user.location,
|
||||||
|
phone: user.phone,
|
||||||
|
countryId: user.countryId,
|
||||||
|
isSuspended: user.isSuspended,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Compte créé avec succès', user: userWithoutPassword },
|
{ message: 'Compte créé avec succès', user: safeUser },
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// CSRF Protection
|
// CSRF Protection
|
||||||
const isOriginValid = verifyOrigin(request);
|
const isOriginValid = verifyOrigin(request);
|
||||||
@@ -61,7 +70,8 @@ export async function POST(request: NextRequest) {
|
|||||||
where: { id: 'singleton' }
|
where: { id: 'singleton' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const siteName = settings?.siteName || 'Afrohub';
|
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
|
||||||
|
const safeUserName = escapeHtml(user.name || '');
|
||||||
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
||||||
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
||||||
|
|
||||||
@@ -78,7 +88,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Replace placeholders
|
// Replace placeholders
|
||||||
html = html
|
html = html
|
||||||
.replace(/{name}/g, user.name)
|
.replace(/{name}/g, safeUserName)
|
||||||
.replace(/{verifyUrl}/g, verifyUrl)
|
.replace(/{verifyUrl}/g, verifyUrl)
|
||||||
.replace(/{siteName}/g, siteName);
|
.replace(/{siteName}/g, siteName);
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const { token, password } = await request.json();
|
const { token, password } = await request.json();
|
||||||
|
|
||||||
if (!token || !password) {
|
if (!token || !password || typeof password !== 'string') {
|
||||||
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
|
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Le mot de passe doit contenir au moins 8 caractères' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
resetToken: token,
|
resetToken: token,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '../../../../lib/prisma'
|
||||||
|
import { isAdmin } from '@/lib/auth-utils'
|
||||||
type Params = { params: Promise<{ id: string }> }
|
|
||||||
|
|
||||||
// GET /api/blog/[id]
|
// GET /api/blog/[id]
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -19,12 +18,16 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/blog/[id]
|
// PATCH /api/blog/[id] (Admin only)
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
if (body.title && body.slug === undefined) {
|
if (body.title && body.slug === undefined) {
|
||||||
@@ -39,12 +42,16 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/blog/[id]
|
// DELETE /api/blog/[id] (Admin only)
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
await prisma.blogPost.delete({ where: { id } })
|
await prisma.blogPost.delete({ where: { id } })
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { generateSlug } from '@/lib/utils'
|
import { generateSlug } from '@/lib/utils'
|
||||||
|
import { isAdmin } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/blog
|
// GET /api/blog
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -21,9 +22,13 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/blog
|
// POST /api/blog (Admin only)
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
if (!body.slug && body.title) {
|
if (!body.slug && body.title) {
|
||||||
body.slug = generateSlug(body.title)
|
body.slug = generateSlug(body.title)
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id: paramId } = await context.params;
|
const { id: paramId } = await context.params;
|
||||||
const userId = req.headers.get('x-user-id');
|
const authUser = await getAuthUser(req);
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { value, comment } = await req.json();
|
const { value, comment } = await req.json();
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ export async function POST(
|
|||||||
{ slug: paramId }
|
{ slug: paramId }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
select: { id: true }
|
select: { id: true, ownerId: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!business) {
|
if (!business) {
|
||||||
@@ -35,7 +38,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const businessId = business.id;
|
const businessId = business.id;
|
||||||
const ownerId = (business as any).ownerId;
|
const ownerId = business.ownerId;
|
||||||
|
|
||||||
// 2. Security: Don't allow owner to rate themselves
|
// 2. Security: Don't allow owner to rate themselves
|
||||||
if (userId === ownerId) {
|
if (userId === ownerId) {
|
||||||
@@ -85,7 +88,7 @@ export async function POST(
|
|||||||
|
|
||||||
// 3. Aggregate all ratings for this business
|
// 3. Aggregate all ratings for this business
|
||||||
const stats = await prisma.rating.aggregate({
|
const stats = await prisma.rating.aggregate({
|
||||||
where: { businessId },
|
where: { businessId, status: 'APPROVED' },
|
||||||
_avg: { value: true },
|
_avg: { value: true },
|
||||||
_count: { value: true }
|
_count: { value: true }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id: paramId } = await context.params;
|
const { id: paramId } = await context.params;
|
||||||
const userId = req.headers.get('x-user-id');
|
const authUser = await getAuthUser(req);
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ rating: null });
|
return NextResponse.json({ rating: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resolve actual Business UUID if paramId is a slug
|
// Resolve actual Business UUID if paramId is a slug
|
||||||
const business = await prisma.business.findFirst({
|
const business = await prisma.business.findFirst({
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
|
import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> }
|
|
||||||
|
|
||||||
// GET /api/businesses/[id]
|
// GET /api/businesses/[id]
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -50,11 +48,56 @@ export async function PATCH(
|
|||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
|
const existing = await prisma.business.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { ownerId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Boutique non trouvée' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = authUser.role === 'ADMIN'
|
||||||
|
const isOwner = existing.ownerId === authUser.id
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Whitelist allowed fields for owners; sensitive fields require ADMIN role
|
||||||
|
const allowedOwnerFields = [
|
||||||
|
'name', 'slug', 'category', 'categoryId', 'suggestedCategory',
|
||||||
|
'location', 'countryId', 'city', 'description', 'logoUrl',
|
||||||
|
'coverUrl', 'coverPosition', 'coverZoom', 'videoUrl',
|
||||||
|
'contactEmail', 'contactPhone', 'websiteUrl', 'socialLinks',
|
||||||
|
'showEmail', 'showPhone', 'showSocials', 'tags',
|
||||||
|
'founderName', 'founderImageUrl', 'keyMetric', 'isActive'
|
||||||
|
]
|
||||||
|
const allowedAdminFields = [
|
||||||
|
...allowedOwnerFields,
|
||||||
|
'verified', 'plan', 'isFeatured', 'isHomeFeatured',
|
||||||
|
'isSuspended', 'suspensionReason', 'metaTitle', 'metaDescription'
|
||||||
|
]
|
||||||
|
|
||||||
|
const permittedKeys = isAdmin ? allowedAdminFields : allowedOwnerFields
|
||||||
|
const updateData: Record<string, any> = {}
|
||||||
|
for (const key of permittedKeys) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
updateData[key] = body[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const business = await prisma.business.update({
|
const business = await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: body,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
owner: {
|
owner: {
|
||||||
select: USER_SAFE_SELECT
|
select: USER_SAFE_SELECT
|
||||||
@@ -75,8 +118,12 @@ export async function DELETE(
|
|||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const headerUserId = request.headers.get('x-user-id')
|
|
||||||
|
|
||||||
// 1. Find business to check owner
|
// 1. Find business to check owner
|
||||||
const business = await prisma.business.findUnique({
|
const business = await prisma.business.findUnique({
|
||||||
@@ -89,9 +136,8 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Auth check (Owner or Admin)
|
// 2. Auth check (Owner or Admin)
|
||||||
const user = headerUserId ? await prisma.user.findUnique({ where: { id: headerUserId } }) : null
|
const isAdmin = authUser.role === 'ADMIN'
|
||||||
const isAdmin = user?.role === 'ADMIN'
|
const isOwner = business.ownerId === authUser.id
|
||||||
const isOwner = business.ownerId === headerUserId
|
|
||||||
|
|
||||||
if (!isOwner && !isAdmin) {
|
if (!isOwner && !isAdmin) {
|
||||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
|
||||||
@@ -101,13 +147,13 @@ export async function DELETE(
|
|||||||
await prisma.business.delete({ where: { id } })
|
await prisma.business.delete({ where: { id } })
|
||||||
|
|
||||||
// 4. Update user role if they don't have other businesses
|
// 4. Update user role if they don't have other businesses
|
||||||
if (headerUserId && !isAdmin) {
|
if (!isAdmin) {
|
||||||
const otherBusinesses = await prisma.business.findFirst({
|
const otherBusinesses = await prisma.business.findFirst({
|
||||||
where: { ownerId: headerUserId }
|
where: { ownerId: authUser.id }
|
||||||
})
|
})
|
||||||
if (!otherBusinesses) {
|
if (!otherBusinesses) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: headerUserId },
|
where: { id: authUser.id },
|
||||||
data: { role: 'VISITOR' }
|
data: { role: 'VISITOR' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/businesses/me — Fetch business for the current user
|
// GET /api/businesses/me — Fetch business for the current user
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const business = await prisma.business.findFirst({
|
const business = await prisma.business.findFirst({
|
||||||
where: { ownerId: userId },
|
where: { ownerId: authUser.id },
|
||||||
include: { offers: true }
|
include: { offers: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -91,16 +91,15 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const authUser = await getAuthUser(request)
|
||||||
const headerUserId = request.headers.get('x-user-id')
|
if (!authUser) {
|
||||||
const { ownerId: bodyOwnerId, ...data } = body
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
|
||||||
const ownerId = bodyOwnerId || headerUserId
|
|
||||||
|
|
||||||
if (!ownerId) {
|
|
||||||
return NextResponse.json({ error: 'ownerId est requis (via body ou x-user-id header)' }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { ownerId: _ignoredOwnerId, ...data } = body
|
||||||
|
const ownerId = authUser.id
|
||||||
|
|
||||||
// 1. Sanitize & Validate data
|
// 1. Sanitize & Validate data
|
||||||
const cleanData: any = {
|
const cleanData: any = {
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
@@ -124,11 +123,7 @@ export async function POST(request: NextRequest) {
|
|||||||
showEmail: data.showEmail ?? true,
|
showEmail: data.showEmail ?? true,
|
||||||
showPhone: data.showPhone ?? true,
|
showPhone: data.showPhone ?? true,
|
||||||
showSocials: data.showSocials ?? true,
|
showSocials: data.showSocials ?? true,
|
||||||
verified: data.verified ?? false,
|
|
||||||
viewCount: data.viewCount ?? 0,
|
|
||||||
rating: data.rating ?? 0,
|
|
||||||
tags: Array.isArray(data.tags) ? data.tags : [],
|
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||||
isFeatured: data.isFeatured ?? false,
|
|
||||||
founderName: data.founderName || null,
|
founderName: data.founderName || null,
|
||||||
founderImageUrl: data.founderImageUrl || null,
|
founderImageUrl: data.founderImageUrl || null,
|
||||||
keyMetric: data.keyMetric || null,
|
keyMetric: data.keyMetric || null,
|
||||||
@@ -190,11 +185,13 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Automatic role upgrade
|
// Automatic role upgrade (only if VISITOR, never downgrade ADMIN)
|
||||||
await prisma.user.update({
|
if (authUser.role === 'VISITOR') {
|
||||||
where: { id: ownerId },
|
await prisma.user.update({
|
||||||
data: { role: 'ENTREPRENEUR' }
|
where: { id: ownerId },
|
||||||
});
|
data: { role: 'ENTREPRENEUR' }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Business saved successfully:', business.id);
|
console.log('Business saved successfully:', business.id);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../../lib/prisma'
|
import prisma from '../../../../../lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// PATCH /api/conversations/[id]/archive — Toggle archive status for the user
|
// PATCH /api/conversations/[id]/archive — Toggle archive status for the user
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
@@ -7,18 +8,18 @@ export async function PATCH(
|
|||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const { archived } = await request.json()
|
const { archived } = await request.json()
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedParticipant = await prisma.conversationParticipant.update({
|
const updatedParticipant = await prisma.conversationParticipant.update({
|
||||||
where: {
|
where: {
|
||||||
userId_conversationId: {
|
userId_conversationId: {
|
||||||
userId: userId,
|
userId: authUser.id,
|
||||||
conversationId: id
|
conversationId: id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '../../../../lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/conversations/[id] — Get messages for a conversation
|
// GET /api/conversations/[id] — Get messages for a conversation
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -7,13 +8,15 @@ export async function GET(
|
|||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id
|
||||||
|
|
||||||
// Verify user is a participant
|
// Verify user is a participant
|
||||||
const isParticipant = await prisma.conversationParticipant.findUnique({
|
const isParticipant = await prisma.conversationParticipant.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/conversations — List all conversations for the user
|
// GET /api/conversations — List all conversations for the user
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const businessId = searchParams.get('businessId')
|
const businessId = searchParams.get('businessId')
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils';
|
||||||
|
|
||||||
// GET /api/favorites - Get user's favorites
|
// GET /api/favorites - Get user's favorites
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id');
|
const authUser = await getAuthUser(request);
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const favorites = await prisma.favorite.findMany({
|
const favorites = await prisma.favorite.findMany({
|
||||||
where: { userId },
|
where: { userId: authUser.id },
|
||||||
include: {
|
include: {
|
||||||
business: {
|
business: {
|
||||||
include: {
|
include: {
|
||||||
@@ -32,17 +33,19 @@ export async function GET(request: NextRequest) {
|
|||||||
// POST /api/favorites - Toggle favorite
|
// POST /api/favorites - Toggle favorite
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id');
|
const authUser = await getAuthUser(request);
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { businessId } = body;
|
const { businessId } = body;
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (!businessId) {
|
if (!businessId) {
|
||||||
return NextResponse.json({ error: 'businessId requis' }, { status: 400 });
|
return NextResponse.json({ error: 'businessId requis' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id;
|
||||||
|
|
||||||
// Resolve business ID (in case slug was passed)
|
// Resolve business ID (in case slug was passed)
|
||||||
const business = await prisma.business.findFirst({
|
const business = await prisma.business.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,9 +1,43 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { GoogleGenAI } from '@google/genai'
|
import { GoogleGenAI } from '@google/genai'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
|
const MAX_REQUESTS_PER_WINDOW = 10
|
||||||
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
|
||||||
|
function checkRateLimit(key: string): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = rateLimitMap.get(key)
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
rateLimitMap.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
entry.count += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/gemini — AI content generation
|
// POST /api/gemini — AI content generation
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authentification requise' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkRateLimit(authUser.id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Trop de requêtes IA. Veuillez patienter une minute.' },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = process.env.GEMINI_API_KEY
|
const apiKey = process.env.GEMINI_API_KEY
|
||||||
if (!apiKey || apiKey === 'PLACEHOLDER_API_KEY') {
|
if (!apiKey || apiKey === 'PLACEHOLDER_API_KEY') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -14,7 +48,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const ai = new GoogleGenAI({ apiKey })
|
const ai = new GoogleGenAI({ apiKey })
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { action, name, category, keywords } = body
|
const action = typeof body.action === 'string' ? body.action : ''
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 100) : ''
|
||||||
|
const category = typeof body.category === 'string' ? body.category.trim().slice(0, 100) : ''
|
||||||
|
const keywords = typeof body.keywords === 'string' ? body.keywords.trim().slice(0, 300) : ''
|
||||||
|
|
||||||
if (action === 'description') {
|
if (action === 'description') {
|
||||||
const prompt = `
|
const prompt = `
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
{
|
|
||||||
status: 'ok',
|
|
||||||
service: 'afrohub-front',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '../../../../lib/prisma'
|
||||||
|
import { isAdmin } from '@/lib/auth-utils'
|
||||||
type Params = { params: Promise<{ id: string }> }
|
|
||||||
|
|
||||||
// GET /api/interviews/[id]
|
// GET /api/interviews/[id]
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -19,12 +18,16 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/interviews/[id]
|
// PATCH /api/interviews/[id] (Admin only)
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const interview = await prisma.interview.update({ where: { id }, data: body })
|
const interview = await prisma.interview.update({ where: { id }, data: body })
|
||||||
@@ -35,12 +38,16 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/interviews/[id]
|
// DELETE /api/interviews/[id] (Admin only)
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
await prisma.interview.delete({ where: { id } })
|
await prisma.interview.delete({ where: { id } })
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { isAdmin } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/interviews
|
// GET /api/interviews
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -20,9 +21,13 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/interviews
|
// POST /api/interviews (Admin only)
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
if (!(await isAdmin(request))) {
|
||||||
|
return NextResponse.json({ error: 'Accès réservé aux administrateurs' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const interview = await prisma.interview.create({ data: body })
|
const interview = await prisma.interview.create({ data: body })
|
||||||
return NextResponse.json(interview, { status: 201 })
|
return NextResponse.json(interview, { status: 201 })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../../lib/prisma'
|
import prisma from '../../../../../lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// POST /api/messages/[id]/report — Report a message for moderation
|
// POST /api/messages/[id]/report — Report a message for moderation
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -7,14 +8,16 @@ export async function POST(
|
|||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const { reason } = await request.json()
|
const { reason } = await request.json()
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id
|
||||||
|
|
||||||
// Verify message exists
|
// Verify message exists
|
||||||
const message = await prisma.message.findUnique({
|
const message = await prisma.message.findUnique({
|
||||||
where: { id }
|
where: { id }
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// POST /api/messages — Send a message or start a conversation
|
// POST /api/messages — Send a message or start a conversation
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
const { businessId, conversationId, content } = await request.json()
|
const { businessId, conversationId, content } = await request.json()
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return NextResponse.json({ error: 'Contenu manquant' }, { status: 400 })
|
return NextResponse.json({ error: 'Contenu manquant' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id
|
||||||
|
|
||||||
const unreadCount = await prisma.message.count({
|
const unreadCount = await prisma.message.count({
|
||||||
where: {
|
where: {
|
||||||
conversation: {
|
conversation: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '../../../../lib/prisma'
|
import prisma from '../../../../lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
type Params = { params: Promise<{ id: string }> }
|
|
||||||
|
|
||||||
// GET /api/offers/[id]
|
// GET /api/offers/[id]
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -22,17 +21,43 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/offers/[id]
|
// PATCH /api/offers/[id] (Owner or Admin only)
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
|
const existing = await prisma.offer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { business: { select: { ownerId: true } } }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Offre non trouvée' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
const { title, description, price, type, imageUrl } = body
|
||||||
|
|
||||||
const offer = await prisma.offer.update({
|
const offer = await prisma.offer.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: body,
|
data: {
|
||||||
|
...(title !== undefined ? { title } : {}),
|
||||||
|
...(description !== undefined ? { description } : {}),
|
||||||
|
...(price !== undefined ? { price } : {}),
|
||||||
|
...(type !== undefined ? { type } : {}),
|
||||||
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
||||||
|
},
|
||||||
include: { business: true },
|
include: { business: true },
|
||||||
})
|
})
|
||||||
return NextResponse.json(offer)
|
return NextResponse.json(offer)
|
||||||
@@ -42,13 +67,31 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/offers/[id]
|
// DELETE /api/offers/[id] (Owner or Admin only)
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
|
const existing = await prisma.offer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { business: { select: { ownerId: true } } }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: 'Offre non trouvée' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.offer.delete({ where: { id } })
|
await prisma.offer.delete({ where: { id } })
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/offers — optionally filter by businessId
|
// GET /api/offers — optionally filter by businessId
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -22,11 +23,16 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/offers
|
// POST /api/offers (Owner or Admin only)
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { businessId } = body
|
const { businessId, title, description, price, type, imageUrl } = body
|
||||||
|
|
||||||
if (!businessId) {
|
if (!businessId) {
|
||||||
return NextResponse.json({ error: 'businessId manquant' }, { status: 400 })
|
return NextResponse.json({ error: 'businessId manquant' }, { status: 400 })
|
||||||
@@ -44,6 +50,10 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 })
|
return NextResponse.json({ error: 'Entreprise non trouvée' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (business.ownerId !== authUser.id && authUser.role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé : vous n\'êtes pas propriétaire de cette entreprise' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the specific limit for this tier from the database
|
// Fetch the specific limit for this tier from the database
|
||||||
const planDetails = await prisma.pricingPlan.findUnique({
|
const planDetails = await prisma.pricingPlan.findUnique({
|
||||||
where: { tier: business.plan }
|
where: { tier: business.plan }
|
||||||
@@ -63,9 +73,16 @@ export async function POST(request: NextRequest) {
|
|||||||
}, { status: 403 })
|
}, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create offer
|
// 3. Create offer with whitelisted fields
|
||||||
const offer = await prisma.offer.create({
|
const offer = await prisma.offer.create({
|
||||||
data: body,
|
data: {
|
||||||
|
businessId,
|
||||||
|
title: title || '',
|
||||||
|
description: description || '',
|
||||||
|
price: price ?? '',
|
||||||
|
type: type || 'PRODUCT',
|
||||||
|
imageUrl: imageUrl || '',
|
||||||
|
},
|
||||||
include: { business: true },
|
include: { business: true },
|
||||||
})
|
})
|
||||||
return NextResponse.json(offer, { status: 201 })
|
return NextResponse.json(offer, { status: 201 })
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { getAuthUser } from '@/lib/auth-utils';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ id: string }> }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id: ratingId } = await context.params;
|
const { id: ratingId } = await context.params;
|
||||||
const userId = req.headers.get('x-user-id');
|
const authUser = await getAuthUser(req);
|
||||||
|
|
||||||
if (!userId) {
|
if (!authUser) {
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userId = authUser.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { reply } = await req.json();
|
const { reply } = await req.json();
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,52 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { getAuthUser, USER_SAFE_SELECT } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// PATCH /api/users/me — Update personal user profile
|
// GET /api/users/me — Get current authenticated user profile
|
||||||
export async function PATCH(
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<any> }
|
|
||||||
): Promise<Response> {
|
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id')
|
const authUser = await getAuthUser(request)
|
||||||
const data = await request.json()
|
if (!authUser) {
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: authUser.id },
|
||||||
|
select: USER_SAFE_SELECT
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Utilisateur introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(user)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET /api/users/me error:', error)
|
||||||
|
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/users/me — Update personal user profile
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const authUser = await getAuthUser(request)
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Utilisateur non identifié' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.json()
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: authUser.id },
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
bio: data.bio,
|
bio: data.bio,
|
||||||
location: data.location,
|
location: data.location,
|
||||||
},
|
},
|
||||||
select: {
|
select: USER_SAFE_SELECT
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
phone: true,
|
|
||||||
bio: true,
|
|
||||||
location: true,
|
|
||||||
avatar: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json(updatedUser)
|
return NextResponse.json(updatedUser)
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { getAuthUser, USER_SAFE_SELECT } from '@/lib/auth-utils';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const authUser = await getAuthUser(request);
|
||||||
const email = searchParams.get('email');
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!email) {
|
const { searchParams } = new URL(request.url);
|
||||||
return NextResponse.json({ error: 'Email requis' }, { status: 400 });
|
const email = searchParams.get('email')?.toLowerCase().trim() || authUser.email.toLowerCase();
|
||||||
|
|
||||||
|
// Seul l'utilisateur lui-même ou un administrateur peut consulter ce profil par email
|
||||||
|
if (authUser.role !== 'ADMIN' && authUser.email.toLowerCase() !== email) {
|
||||||
|
return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
select: {
|
select: USER_SAFE_SELECT
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
avatar: true,
|
|
||||||
phone: true,
|
|
||||||
bio: true,
|
|
||||||
location: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { isAdmin, USER_SAFE_SELECT } from '@/lib/auth-utils'
|
import { isAdmin, USER_SAFE_SELECT } from '@/lib/auth-utils'
|
||||||
|
|
||||||
// GET /api/users
|
// GET /api/users (Admin only)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!(await isAdmin(request))) {
|
if (!(await isAdmin(request))) {
|
||||||
@@ -23,7 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/users (Admin only)
|
// POST /api/users (Admin only — hashes password and whitelists fields)
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!(await isAdmin(request))) {
|
if (!(await isAdmin(request))) {
|
||||||
@@ -31,12 +32,33 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
const email = body.email?.toLowerCase().trim()
|
||||||
// Note: For admins, we might want to allow creating users directly,
|
const name = body.name?.trim()
|
||||||
// but we should still ensure password hashing if provided.
|
const rawPassword = body.password
|
||||||
// For now, we just restrict it to admins to prevent the public leak.
|
|
||||||
|
if (!email || !name || !rawPassword || rawPassword.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Nom, email et mot de passe (min. 8 caractères) requis' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(rawPassword, 10)
|
||||||
|
const allowedRole = ['VISITOR', 'ENTREPRENEUR', 'ADMIN'].includes(body.role)
|
||||||
|
? body.role
|
||||||
|
: 'VISITOR'
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: body,
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: allowedRole,
|
||||||
|
phone: body.phone || null,
|
||||||
|
bio: body.bio || null,
|
||||||
|
location: body.location || null,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
select: USER_SAFE_SELECT
|
select: USER_SAFE_SELECT
|
||||||
})
|
})
|
||||||
return NextResponse.json(user, { status: 201 })
|
return NextResponse.json(user, { status: 201 })
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ const DashboardContent = () => {
|
|||||||
|
|
||||||
const fetchBusiness = async () => {
|
const fetchBusiness = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/businesses/me', {
|
const res = await fetch('/api/businesses/me');
|
||||||
headers: { 'x-user-id': user.id }
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && !data.error) {
|
if (data && !data.error) {
|
||||||
setBusiness(data);
|
setBusiness(data);
|
||||||
@@ -77,26 +75,9 @@ const DashboardContent = () => {
|
|||||||
// Fetch unread messages count
|
// Fetch unread messages count
|
||||||
const fetchUnreadCount = async () => {
|
const fetchUnreadCount = async () => {
|
||||||
if (!user || !isMounted) return;
|
if (!user || !isMounted) return;
|
||||||
|
|
||||||
// If user has no id, try to find it via email (Session Reconciliation)
|
|
||||||
let effectiveUserId = user.id;
|
|
||||||
if (!effectiveUserId || effectiveUserId === 'undefined' || effectiveUserId === 'null') {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/users/profile?email=${encodeURIComponent(user.email)}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.id) {
|
|
||||||
effectiveUserId = data.id;
|
|
||||||
// Note: Ideally we'd update the global user object here too
|
|
||||||
} else {
|
|
||||||
return; // Can't count without ID
|
|
||||||
}
|
|
||||||
} catch (e) { return; }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/messages/unread', {
|
const res = await fetch('/api/messages/unread');
|
||||||
headers: { 'x-user-id': effectiveUserId }
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
setUnreadCount(data.count);
|
setUnreadCount(data.count);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Facebook, Twitter, Instagram, Linkedin } from 'lucide-react';
|
import { Facebook, Twitter, Instagram, Linkedin } from 'lucide-react';
|
||||||
|
import { safeUrl } from '@/lib/utils';
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
settings?: any;
|
settings?: any;
|
||||||
@@ -49,7 +50,7 @@ const Footer = ({ settings }: FooterProps) => {
|
|||||||
{socialLinks.map((social, index) => (
|
{socialLinks.map((social, index) => (
|
||||||
<a
|
<a
|
||||||
key={index}
|
key={index}
|
||||||
href={social.url}
|
href={safeUrl(social.url)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-2 rounded-full border border-gray-800 text-gray-400 hover:text-brand-500 hover:border-brand-500/50 hover:bg-white/5 transition-all"
|
className="p-2 rounded-full border border-gray-800 text-gray-400 hover:text-brand-500 hover:border-brand-500/50 hover:bg-white/5 transition-all"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Search, MapPin, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
import { Search, MapPin, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||||
|
import { safeUrl } from '@/lib/utils';
|
||||||
|
|
||||||
interface HomeSlide {
|
interface HomeSlide {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -130,7 +131,7 @@ const HomeSlider = ({ slides, onSearch }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
<Link
|
<Link
|
||||||
href={slide.type === 'BUSINESS' ? `/annuaire/${slide.business?.id}` : (slide.linkUrl || '#')}
|
href={slide.type === 'BUSINESS' ? `/annuaire/${slide.business?.id}` : safeUrl(slide.linkUrl)}
|
||||||
className="inline-flex items-center gap-2 bg-brand-600 text-white px-8 py-4 rounded-lg font-bold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/20 hover:scale-105"
|
className="inline-flex items-center gap-2 bg-brand-600 text-white px-8 py-4 rounded-lg font-bold hover:bg-brand-700 transition-all shadow-lg shadow-brand-600/20 hover:scale-105"
|
||||||
>
|
>
|
||||||
{slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'}
|
{slide.type === 'BUSINESS' ? 'Voir la fiche' : 'En savoir plus'}
|
||||||
|
|||||||
@@ -22,9 +22,27 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const savedUser = localStorage.getItem('afro_user');
|
const savedUser = localStorage.getItem('afro_user');
|
||||||
if (savedUser) {
|
if (savedUser) {
|
||||||
setUser(JSON.parse(savedUser));
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('afro_user');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronize user session from signed HttpOnly cookie
|
||||||
|
fetch('/api/users/me')
|
||||||
|
.then(async res => {
|
||||||
|
if (res.ok) {
|
||||||
|
const freshUser = await res.json();
|
||||||
|
setUser(freshUser);
|
||||||
|
localStorage.setItem('afro_user', JSON.stringify(freshUser));
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('afro_user');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error verifying session:', err));
|
||||||
|
|
||||||
// Fetch settings
|
// Fetch settings
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -47,6 +65,9 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem('afro_user');
|
localStorage.removeItem('afro_user');
|
||||||
|
fetch('/api/auth/logout', { method: 'POST' }).catch(err =>
|
||||||
|
console.error('Error logging out:', err)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export const USER_SESSION_COOKIE_NAME = 'afro_session';
|
||||||
|
export const DEFAULT_USER_SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||||
|
|
||||||
|
export interface UserSessionPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const USER_SAFE_SELECT = {
|
export const USER_SAFE_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -10,25 +22,163 @@ export const USER_SAFE_SELECT = {
|
|||||||
bio: true,
|
bio: true,
|
||||||
location: true,
|
location: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
|
countryId: true,
|
||||||
|
isSuspended: true,
|
||||||
|
suspensionReason: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAuthUser(request: NextRequest) {
|
function getJwtSecret(): string {
|
||||||
const userId = request.headers.get('x-user-id');
|
const secret = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET || process.env.ADMIN_JWT_SECRET;
|
||||||
if (!userId) return null;
|
if (!secret) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error('CRITICAL: JWT_SECRET or NEXTAUTH_SECRET environment variable must be set in production.');
|
||||||
|
}
|
||||||
|
return 'dev_only_afrohub_user_jwt_secret_key_change_in_prod';
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(str: string): string {
|
||||||
|
return Buffer.from(str, 'utf-8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(str: string): string {
|
||||||
|
return Buffer.from(str, 'base64url').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCryptoKey(): Promise<CryptoKey> {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
return await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(getJwtSecret()),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign', 'verify']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signUserToken(
|
||||||
|
payload: Omit<UserSessionPayload, 'exp' | 'iat'>,
|
||||||
|
expiresInSeconds: number = DEFAULT_USER_SESSION_DURATION
|
||||||
|
): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const fullPayload: UserSessionPayload = {
|
||||||
|
...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 = Buffer.from(signatureBuffer).toString('base64url');
|
||||||
|
return `${dataToSign}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyUserToken(token?: string | null): Promise<UserSessionPayload | 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 {
|
try {
|
||||||
|
const key = await getCryptoKey();
|
||||||
|
const signatureBytes = Buffer.from(signatureB64, 'base64url');
|
||||||
|
|
||||||
|
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 UserSessionPayload;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (!payload.exp || payload.exp < now || !payload.userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserSessionCookie(
|
||||||
|
token: string,
|
||||||
|
maxAgeSeconds: number = DEFAULT_USER_SESSION_DURATION
|
||||||
|
): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(USER_SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: maxAgeSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearUserSessionCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(USER_SESSION_COOKIE_NAME, '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthUser(request?: NextRequest) {
|
||||||
|
try {
|
||||||
|
let token: string | undefined;
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
token = request.cookies.get(USER_SESSION_COOKIE_NAME)?.value;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
token = cookieStore.get(USER_SESSION_COOKIE_NAME)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await verifyUserToken(token);
|
||||||
|
if (!payload?.userId) return null;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: payload.userId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
role: true,
|
role: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
isSuspended: true
|
isSuspended: true,
|
||||||
|
deletedAt: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching auth user:', error);
|
console.error('Error fetching auth user:', error);
|
||||||
@@ -36,7 +186,7 @@ export async function getAuthUser(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isAdmin(request: NextRequest) {
|
export async function isAdmin(request?: NextRequest) {
|
||||||
const user = await getAuthUser(request);
|
const user = await getAuthUser(request);
|
||||||
return user?.role === 'ADMIN';
|
return user?.role === 'ADMIN' && !user.isSuspended;
|
||||||
}
|
}
|
||||||
|
|||||||
18
lib/geo.ts
18
lib/geo.ts
@@ -1,3 +1,5 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
export async function getGeoInfo(ip: string, headers: Headers) {
|
export async function getGeoInfo(ip: string, headers: Headers) {
|
||||||
// 1. Try platform-specific headers first (Vercel, Cloudflare, etc.)
|
// 1. Try platform-specific headers first (Vercel, Cloudflare, etc.)
|
||||||
const headerCountry =
|
const headerCountry =
|
||||||
@@ -6,25 +8,33 @@ export async function getGeoInfo(ip: string, headers: Headers) {
|
|||||||
headers.get('x-country-code');
|
headers.get('x-country-code');
|
||||||
|
|
||||||
if (headerCountry && headerCountry !== 'XX') {
|
if (headerCountry && headerCountry !== 'XX') {
|
||||||
// If we have a country code, we might still want to get the name
|
|
||||||
// For now, let's keep it simple or use a lookup map
|
|
||||||
return {
|
return {
|
||||||
country: headerCountry,
|
country: headerCountry,
|
||||||
city: headers.get('x-vercel-ip-city') || 'Unknown'
|
city: headers.get('x-vercel-ip-city') || 'Unknown'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanIp = ip.trim();
|
||||||
|
|
||||||
// 2. Handle localhost
|
// 2. Handle localhost
|
||||||
if (ip === '127.0.0.1' || ip === '::1' || ip.includes('127.0.0.1')) {
|
if (cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.includes('127.0.0.1')) {
|
||||||
return { country: 'Local / Dev', city: 'Localhost' };
|
return { country: 'Local / Dev', city: 'Localhost' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate IPv4/IPv6 syntax to prevent SSRF / path traversal via spoofed X-Forwarded-For
|
||||||
|
if (isIP(cleanIp) === 0) {
|
||||||
|
return { country: 'Unknown', city: 'Unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Fallback to external API
|
// 3. Fallback to external API
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
|
||||||
|
|
||||||
const response = await fetch(`http://ip-api.com/json/${ip}`, { signal: controller.signal });
|
const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(cleanIp)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { 'Accept-Encoding': 'gzip, deflate' },
|
||||||
|
});
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import nodemailer from 'nodemailer';
|
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({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||||
@@ -15,7 +9,7 @@ const transporter = nodemailer.createTransport({
|
|||||||
pass: process.env.SMTP_PASS,
|
pass: process.env.SMTP_PASS,
|
||||||
},
|
},
|
||||||
tls: {
|
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 {
|
try {
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await transporter.sendMail(mailOptions);
|
||||||
console.log('Email sent: ' + info.response);
|
|
||||||
return info;
|
return info;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending email:', error);
|
console.error('Error sending email:', error);
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export function sanitizeHTML(html: string): string {
|
|||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
'a': ['href', 'target', 'rel'],
|
'a': ['href', 'target', 'rel'],
|
||||||
'img': ['src', 'alt'],
|
'img': ['src', 'alt'],
|
||||||
'*': ['class', 'style']
|
'*': ['class']
|
||||||
},
|
},
|
||||||
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
||||||
// Standard URLs only
|
// Standard URLs only
|
||||||
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'tel'],
|
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
25
lib/utils.ts
25
lib/utils.ts
@@ -11,3 +11,28 @@ export function generateSlug(text: string): string {
|
|||||||
.replace(/^-+/, '') // trim - from start of text
|
.replace(/^-+/, '') // trim - from start of text
|
||||||
.replace(/-+$/, ''); // trim - from end of text
|
.replace(/-+$/, ''); // trim - from end of text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates external or relative URLs to prevent javascript:/data:/vbscript: XSS in href attributes.
|
||||||
|
*/
|
||||||
|
export function safeUrl(url?: string | null): string {
|
||||||
|
if (!url || typeof url !== 'string') return '#';
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return '#';
|
||||||
|
|
||||||
|
// Allow safe relative paths (starting with single `/` and not `//` or `/\`)
|
||||||
|
if (trimmed.startsWith('/') && !trimmed.startsWith('//') && !trimmed.startsWith('/\\')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
|||||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
1320
package-lock.json
generated
1320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.x"
|
"node": ">=22.23.4"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"admin"
|
"admin"
|
||||||
@@ -32,22 +32,21 @@
|
|||||||
"@prisma/config": "6.19.3",
|
"@prisma/config": "6.19.3",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.1",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.2.3",
|
"next": "^16.4.3",
|
||||||
"nodemailer": "^8.0.6",
|
"nodemailer": "^10.3.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.20.0",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.23",
|
||||||
"prisma": "6.19.3",
|
"prisma": "6.19.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-is": "^19.2.6",
|
"react-is": "^19.2.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -64,7 +63,13 @@
|
|||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.23",
|
||||||
|
"postcss-selector-parser": "^6.1.5",
|
||||||
|
"deepmerge-ts": "^8.0.0",
|
||||||
|
"shell-quote": "^1.8.5",
|
||||||
|
"d3-color": "^3.1.0",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"quill": "2.0.2",
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user