10 Commits

Author SHA1 Message Date
C_Ma_For 99c3380d00 feat: dockerfile + docker-compose + déploiement VPS gestion.materiaux-destock.fr 2026-06-07 00:27:16 +02:00
C_Ma_For 51f87c0672 feat: app shell avec sidebar, header et pages placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:20:57 +02:00
C_Ma_For 8d06706a24 feat: page login avec gestion erreurs et rate limiting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:20:36 +02:00
C_Ma_For 44b3b4dd8d fix: improve type safety in NextAuth callbacks (remove any, add explanatory comments) 2026-06-07 00:08:11 +02:00
C_Ma_For 38dc6efc09 feat: middleware protection routes + restriction admin /parametres
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:05:28 +02:00
C_Ma_For f9a14b009a feat: NextAuth credentials + JWT 8h + rate limiting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:05:14 +02:00
C_Ma_For 2acf899465 feat: rate limiting connexion (5 essais / 15 min) 2026-06-06 17:24:19 +02:00
C_Ma_For dbb155cb3a fix: remove hardcoded seed password from package.json (use SEED_ADMIN_PASSWORD env var) 2026-06-06 17:22:25 +02:00
C_Ma_For 192def9008 feat: prisma schema User + SyncRun + seed admin
Sets up Prisma 7 with SQLite (better-sqlite3 adapter), User and SyncRun models, migration, and admin seed account for mathieu.fort@gmail.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 17:15:54 +02:00
C_Ma_For 1b60fd79f7 chore: replace boilerplate page.tsx with minimal placeholder 2026-06-06 16:21:34 +02:00
35 changed files with 1340 additions and 111 deletions
+18
View File
@@ -0,0 +1,18 @@
# Auth
NEXTAUTH_URL=https://gestion.materiaux-destock.fr
NEXTAUTH_SECRET=<générer avec: openssl rand -base64 32>
# Shopify (remplir lors du Plan 2)
SHOPIFY_STORE_DOMAIN=materiaux-destock.myshopify.com
SHOPIFY_ADMIN_API_TOKEN=shpat_...
# Google Sheets (remplir lors du Plan 3)
GOOGLE_SERVICE_ACCOUNT_EMAIL=...@....iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
GOOGLE_SHEETS_ID=
# Base de données
DATABASE_URL="file:./data/app.db"
# Seed (premier déploiement uniquement)
SEED_ADMIN_PASSWORD=
+9
View File
@@ -28,9 +28,18 @@ yarn-error.log*
# local env files
.env*.local
# prisma env (contains DATABASE_URL)
.env
# database files
/data/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
.env.production
+48
View File
@@ -0,0 +1,48 @@
# Étape 1 : dépendances
FROM node:20-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci
# Étape 2 : build
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="file:./data/app.db"
RUN npx prisma generate
RUN npm run build
# Étape 3 : image de production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
COPY --from=builder /app/node_modules/@prisma/adapter-better-sqlite3 ./node_modules/@prisma/adapter-better-sqlite3
COPY --from=builder /app/node_modules/dotenv ./node_modules/dotenv
COPY scripts/start.sh ./start.sh
RUN mkdir -p data && chown -R nextjs:nodejs data .
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["sh", "start.sh"]
+15
View File
@@ -0,0 +1,15 @@
services:
app:
build: .
image: gestion-materiaux-destock:latest
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data
networks:
- web
networks:
web:
external: true
+3
View File
@@ -7,6 +7,9 @@ const config: Config = {
coverageProvider: 'v8',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
export default createJestConfig(config)
+3 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: 'standalone',
};
export default nextConfig;
+576 -7
View File
@@ -8,8 +8,10 @@
"name": "gestion-md",
"version": "0.1.0",
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"next": "14.2.35",
"next-auth": "^4.24.14",
"react": "^18",
@@ -31,6 +33,7 @@
"prisma": "^7.8.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5"
}
},
@@ -621,6 +624,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1938,6 +1965,16 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@prisma/adapter-better-sqlite3": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.8.0.tgz",
"integrity": "sha512-9p0HvQNaRoOaUForDcp1MPIjylmCamYQjQpEogpdesLr14g3NwAsYR3bHL9wFi8tn21TPDVQPzcZi+SxXEokSA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/driver-adapter-utils": "7.8.0",
"better-sqlite3": "^12.6.0"
}
},
"node_modules/@prisma/client": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
@@ -1985,7 +2022,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz",
"integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@@ -2014,6 +2050,15 @@
"zeptomatch": "2.1.0"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz",
"integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
}
},
"node_modules/@prisma/engines": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
@@ -2480,6 +2525,34 @@
}
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -3313,6 +3386,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4208,6 +4294,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
@@ -4237,6 +4343,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
@@ -4248,6 +4368,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"dev": true,
@@ -4325,6 +4465,30 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4572,6 +4736,12 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
@@ -4724,6 +4894,13 @@
"node": ">= 0.6"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"devOptional": true,
@@ -4864,6 +5041,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -4879,6 +5071,15 @@
}
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"dev": true,
@@ -4971,6 +5172,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -4986,6 +5196,16 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
@@ -5087,6 +5307,15 @@
"node": ">=14"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -5745,6 +5974,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
@@ -5880,6 +6118,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
@@ -5953,6 +6197,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"dev": true,
@@ -6145,6 +6395,12 @@
"giget": "dist/cli.mjs"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.3.10",
"dev": true,
@@ -6456,6 +6712,26 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"dev": true,
@@ -6528,7 +6804,12 @@
},
"node_modules/inherits": {
"version": "2.0.4",
"dev": true,
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internal-slot": {
@@ -8494,6 +8775,18 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -8517,7 +8810,6 @@
},
"node_modules/minimist": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -8531,6 +8823,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
@@ -8596,6 +8894,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"dev": true,
@@ -8730,6 +9034,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-exports-info": {
"version": "1.6.0",
"dev": true,
@@ -9077,7 +9393,6 @@
},
"node_modules/once": {
"version": "1.4.0",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -9638,6 +9953,33 @@
"preact": ">=10"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"dev": true,
@@ -9715,6 +10057,16 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"dev": true,
@@ -9759,6 +10111,30 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -9820,6 +10196,20 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
@@ -10144,6 +10534,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"dev": true,
@@ -10204,7 +10614,6 @@
},
"node_modules/semver": {
"version": "7.8.2",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -10360,6 +10769,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -10468,6 +10922,15 @@
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -11128,6 +11591,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -11390,6 +11881,57 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"dev": true,
@@ -11405,6 +11947,18 @@
"version": "2.8.1",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -11632,7 +12186,6 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@@ -11645,6 +12198,13 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -11958,7 +12518,6 @@
},
"node_modules/wrappy": {
"version": "1.0.2",
"dev": true,
"license": "ISC"
},
"node_modules/write-file-atomic": {
@@ -12081,6 +12640,16 @@
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"dev": true,
+8 -1
View File
@@ -6,11 +6,17 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
},
"prisma": {
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"next": "14.2.35",
"next-auth": "^4.24.14",
"react": "^18",
@@ -32,6 +38,7 @@
"prisma": "^7.8.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'gestionnaire',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "SyncRun" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"created" INTEGER NOT NULL DEFAULT 0,
"updated" INTEGER NOT NULL DEFAULT 0,
"deactivated" INTEGER NOT NULL DEFAULT 0,
"errors" INTEGER NOT NULL DEFAULT 0,
"log" TEXT NOT NULL DEFAULT '[]',
CONSTRAINT "SyncRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+30
View File
@@ -0,0 +1,30 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
}
model SyncRun {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
status String
created Int @default(0)
updated Int @default(0)
deactivated Int @default(0)
errors Int @default(0)
log String @default("[]")
}
+23
View File
@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client'
import { hash } from 'bcryptjs'
const prisma = new PrismaClient()
const password = process.env.SEED_ADMIN_PASSWORD
if (!password) {
console.error('SEED_ADMIN_PASSWORD requis')
process.exit(1)
}
await prisma.user.upsert({
where: { email: 'mathieu.fort@gmail.com' },
update: {},
create: {
email: 'mathieu.fort@gmail.com',
name: 'Mathieu',
password: await hash(password, 12),
role: 'admin',
},
})
console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
await prisma.$disconnect()
+33
View File
@@ -0,0 +1,33 @@
import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { hash } from 'bcryptjs'
import path from 'path'
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/dev.db'
const dbPath = dbUrl.startsWith('file:')
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
: dbUrl
const adapter = new PrismaBetterSqlite3({ url: dbPath })
const prisma = new PrismaClient({ adapter } as any)
async function main() {
const password = process.env.SEED_ADMIN_PASSWORD
if (!password) throw new Error('SEED_ADMIN_PASSWORD env var requis')
await prisma.user.upsert({
where: { email: 'mathieu.fort@gmail.com' },
update: {},
create: {
email: 'mathieu.fort@gmail.com',
name: 'Mathieu',
password: await hash(password, 12),
role: 'admin',
},
})
console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
echo "▶ Migration base de données…"
node_modules/.bin/prisma migrate deploy
echo "▶ Démarrage du serveur…"
exec node server.js
+68
View File
@@ -0,0 +1,68 @@
import { authOptions } from '@/lib/auth'
// Mock Prisma
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
},
}))
// Mock rate-limit
jest.mock('@/lib/rate-limit', () => ({
checkRateLimit: jest.fn(() => true),
recordFailedAttempt: jest.fn(),
clearAttempts: jest.fn(),
}))
import { prisma } from '@/lib/prisma'
import { checkRateLimit, recordFailedAttempt, clearAttempts } from '@/lib/rate-limit'
const mockFindUnique = prisma.user.findUnique as jest.Mock
describe('NextAuth authorize', () => {
const authorize = (authOptions.providers[0] as any).options.authorize
beforeEach(() => jest.clearAllMocks())
it('retourne null si credentials manquants', async () => {
const result = await authorize({ email: '', password: '' }, {})
expect(result).toBeNull()
})
it('retourne null si utilisateur introuvable', async () => {
mockFindUnique.mockResolvedValue(null)
const result = await authorize(
{ email: 'a@b.com', password: 'wrong' },
{ headers: {} }
)
expect(result).toBeNull()
expect(recordFailedAttempt).toHaveBeenCalled()
})
it('retourne l\'utilisateur si mot de passe correct', async () => {
const { hash } = await import('bcryptjs')
const hashedPwd = await hash('correct', 12)
mockFindUnique.mockResolvedValue({
id: 'user1',
email: 'a@b.com',
name: 'Alice',
role: 'gestionnaire',
password: hashedPwd,
})
const result = await authorize(
{ email: 'a@b.com', password: 'correct' },
{ headers: {} }
)
expect(result).toEqual({ id: 'user1', email: 'a@b.com', name: 'Alice', role: 'gestionnaire' })
expect(clearAttempts).toHaveBeenCalled()
})
it('lève RATE_LIMITED si bloqué', async () => {
;(checkRateLimit as jest.Mock).mockReturnValue(false)
await expect(
authorize({ email: 'a@b.com', password: 'x' }, { headers: {} })
).rejects.toThrow('RATE_LIMITED')
})
})
+36
View File
@@ -0,0 +1,36 @@
import {
checkRateLimit,
recordFailedAttempt,
clearAttempts,
_resetForTests,
} from '@/lib/rate-limit'
beforeEach(() => _resetForTests())
describe('checkRateLimit', () => {
it('autorise une nouvelle clé', () => {
expect(checkRateLimit('ip:user@test.com')).toBe(true)
})
it('autorise après moins de 5 tentatives', () => {
const key = 'ip:user@test.com'
recordFailedAttempt(key)
recordFailedAttempt(key)
recordFailedAttempt(key)
recordFailedAttempt(key)
expect(checkRateLimit(key)).toBe(true)
})
it('bloque après 5 tentatives', () => {
const key = 'ip:user@test.com'
for (let i = 0; i < 5; i++) recordFailedAttempt(key)
expect(checkRateLimit(key)).toBe(false)
})
it('débloque après clearAttempts', () => {
const key = 'ip:user@test.com'
for (let i = 0; i < 5; i++) recordFailedAttempt(key)
clearAttempts(key)
expect(checkRateLimit(key)).toBe(true)
})
})
+97
View File
@@ -0,0 +1,97 @@
'use client'
import { signIn } from 'next-auth/react'
import { useState, FormEvent } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
setLoading(false)
if (result?.error === 'RATE_LIMITED') {
setError('Trop de tentatives. Réessayez dans 15 minutes.')
} else if (result?.error) {
setError('Email ou mot de passe incorrect.')
} else {
router.push('/')
router.refresh()
}
}
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-extrabold text-white">
Matériaux<span className="text-indigo-400">Destock</span>
</h1>
<p className="text-slate-400 text-sm mt-1">Interface de gestion</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-slate-900 rounded-xl p-6 space-y-4 border border-slate-800"
>
{error && (
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-3 py-2">
{error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wide">
Email
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
placeholder="vous@example.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wide">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg text-sm transition-colors"
>
{loading ? 'Connexion…' : 'Se connecter'}
</button>
</form>
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function ImagesPage() {
return (
<>
<Header title="🖼️ Images" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Images à implémenter (Plan 2)</p></div>
</>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Sidebar } from '@/components/layout/Sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden">{children}</main>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function DashboardPage() {
return (
<>
<Header title="📊 Dashboard" />
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard à implémenter (Plan 4)</p></div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function ParametresPage() {
return (
<>
<Header title="⚙️ Paramètres" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Paramètres à implémenter (Plan 4)</p></div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function ProduitsPage() {
return (
<>
<Header title="📦 Produits" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits à implémenter (Plan 4)</p></div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function SyncPage() {
return (
<>
<Header title="🔄 Sync Sheets" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Sync à implémenter (Plan 3)</p></div>
</>
)
}
+5
View File
@@ -0,0 +1,5 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { SessionProvider } from '@/components/providers/SessionProvider'
const inter = Inter({ subsets: ['latin'] })
@@ -12,7 +13,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
)
}
-101
View File
@@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
interface HeaderProps {
title: string
actions?: React.ReactNode
}
export function Header({ title, actions }: HeaderProps) {
return (
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h1 className="text-base font-bold text-gray-900">{title}</h1>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
type NavItem = {
href: string
label: string
icon: string
adminOnly?: boolean
}
const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊' },
{ href: '/produits', label: 'Produits', icon: '📦' },
{ href: '/images', label: 'Images', icon: '🖼️' },
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
]
export function Sidebar() {
const pathname = usePathname()
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
const visible = NAV_ITEMS.filter(item => !item.adminOnly || isAdmin)
return (
<aside className="w-48 bg-slate-900 flex flex-col h-screen sticky top-0 border-r border-slate-800">
{/* Logo */}
<div className="px-4 py-4 border-b border-slate-800">
<span className="text-sm font-extrabold text-white tracking-tight">
Matériaux<span className="text-indigo-400">Destock</span>
</span>
</div>
{/* Navigation */}
<nav className="flex-1 py-3 overflow-y-auto">
{visible.map(item => {
const isActive =
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 px-4 py-2 text-xs border-l-2 transition-colors ${
isActive
? 'bg-slate-800 text-white border-indigo-500 font-semibold'
: 'text-slate-400 border-transparent hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<span className="text-sm">{item.icon}</span>
{item.label}
</Link>
)
})}
</nav>
{/* User */}
<div className="px-4 py-3 border-t border-slate-800">
<p className="text-xs text-slate-400 truncate mb-1">{session?.user?.name}</p>
<p className="text-xs text-slate-600 truncate mb-2">{session?.user?.email}</p>
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Déconnexion
</button>
</div>
</aside>
)
}
@@ -0,0 +1,6 @@
'use client'
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}
+70
View File
@@ -0,0 +1,70 @@
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { prisma } from './prisma'
import { checkRateLimit, recordFailedAttempt, clearAttempts } from './rate-limit'
export const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
maxAge: 8 * 60 * 60, // 8 heures
},
pages: {
signIn: '/login',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
// NextAuth v4 : les types de base n'incluent pas les champs custom.
// Le module augmentation dans next-auth.d.ts couvre le type Session/JWT
// mais pas le paramètre `user` du callback jwt. Cast nécessaire.
const u = user as typeof user & { role: string }
token.role = u.role
token.id = u.id
}
return token
},
async session({ session, token }) {
if (session.user) {
// session.user est déjà augmenté via next-auth.d.ts (id + role)
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
},
},
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Mot de passe', type: 'password' },
},
async authorize(credentials, req) {
if (!credentials?.email || !credentials?.password) return null
// NextAuth v4 : req est un RequestInternal partiel sans typings complets.
// Le cast est nécessaire pour accéder à x-forwarded-for.
const ip =
(req as any)?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ?? 'unknown'
const key = `${ip}:${credentials.email}`
if (!checkRateLimit(key)) {
throw new Error('RATE_LIMITED')
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
})
if (!user || !(await compare(credentials.password, user.password))) {
recordFailedAttempt(key)
return null
}
clearAttempts(key)
return { id: user.id, email: user.email, name: user.name, role: user.role }
},
}),
],
}
+23
View File
@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import path from 'path'
function createPrismaClient() {
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/dev.db'
// Parse the file path from "file:./data/dev.db" format
const dbPath = dbUrl.startsWith('file:')
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
: dbUrl
const adapter = new PrismaBetterSqlite3({ url: dbPath })
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['error'] : [],
})
}
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+32
View File
@@ -0,0 +1,32 @@
const MAX_ATTEMPTS = 5
const BLOCK_DURATION_MS = 15 * 60 * 1000 // 15 minutes
type Entry = { count: number; blockedAt: number | null }
let attempts = new Map<string, Entry>()
export function checkRateLimit(key: string): boolean {
const now = Date.now()
const entry = attempts.get(key)
if (!entry) return true
if (entry.blockedAt !== null) {
if (now - entry.blockedAt < BLOCK_DURATION_MS) return false
attempts.delete(key)
}
return true
}
export function recordFailedAttempt(key: string): void {
const entry = attempts.get(key) ?? { count: 0, blockedAt: null }
entry.count += 1
if (entry.count >= MAX_ATTEMPTS) entry.blockedAt = Date.now()
attempts.set(key, entry)
}
export function clearAttempts(key: string): void {
attempts.delete(key)
}
/** Réinitialisation pour les tests uniquement */
export function _resetForTests(): void {
attempts = new Map()
}
+27
View File
@@ -0,0 +1,27 @@
import { withAuth } from 'next-auth/middleware'
import { NextResponse } from 'next/server'
export default withAuth(
function middleware(req) {
const { token } = req.nextauth
const { pathname } = req.nextUrl
// Seuls les admins accèdent à /parametres
if (pathname.startsWith('/parametres') && token?.role !== 'admin') {
return NextResponse.redirect(new URL('/', req.url))
}
return NextResponse.next()
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
)
export const config = {
matcher: [
'/((?!login|api/auth|_next/static|_next/image|favicon\\.ico).*)',
],
}
+17
View File
@@ -0,0 +1,17 @@
import type { DefaultSession, DefaultJWT } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: DefaultSession['user'] & {
id: string
role: string
}
}
}
declare module 'next-auth/jwt' {
interface JWT extends DefaultJWT {
id: string
role: string
}
}