commit c7109893b2a15354d3a879dd16d0a7d17fee1804 Author: Pierre Date: Mon Aug 24 12:12:51 2026 +0000 Initial commit: charles.secourit.fr - App de suivi maintenance 360 Partners diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6f5ca1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +.env +dist/ +.git/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..56f5779 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +RUN mkdir -p data +EXPOSE 3008 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8698cb0 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,15 @@ +{ + "name": "charles-backend", + "version": "1.0.0", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "express": "^4.21.0", + "better-sqlite3": "^11.6.0", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "cors": "^2.8.5" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..6a3151e --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,22 @@ +const jwt = require('jsonwebtoken'); +const JWT_SECRET = process.env.JWT_SECRET || 'charles-secourit-2026-secret-key'; + +function generateToken(user) { + return jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' }); +} + +function authMiddleware(req, res, next) { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'Token manquant' }); + try { + req.user = jwt.verify(token, JWT_SECRET); + next(); + } catch { return res.status(401).json({ error: 'Token invalide' }); } +} + +function adminOnly(req, res, next) { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Acces refuse' }); + next(); +} + +module.exports = { generateToken, authMiddleware, adminOnly }; diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..ed29a4c --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,65 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const bcrypt = require('bcryptjs'); + +const dbDir = path.join(__dirname, '..', 'data'); +const db = new Database(path.join(dbDir, 'charles.db')); + +db.pragma('journal_mode = WAL'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'client')) + ); + + CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + company_name TEXT NOT NULL, + parent_id INTEGER, + sort_order INTEGER DEFAULT 0, + has_cloud INTEGER DEFAULT 0, + cloud_details TEXT DEFAULT '', + has_website INTEGER DEFAULT 0, + website_wordpress INTEGER DEFAULT 0, + website_managed_by TEXT DEFAULT '', + website_details TEXT DEFAULT '', + has_emails INTEGER DEFAULT 0, + email_count INTEGER DEFAULT 0, + email_addresses TEXT DEFAULT '[]', + has_team INTEGER DEFAULT 0, + team_members TEXT DEFAULT '[]', + notes TEXT DEFAULT '', + monthly_price REAL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (parent_id) REFERENCES domains(id) + ); +`); + +const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; +if (userCount === 0) { + const insert = db.prepare('INSERT INTO users (username, password_hash, name, role) VALUES (?, ?, ?, ?)'); + insert.run('pierre', bcrypt.hashSync('Charles2026!', 10), 'Pierre', 'admin'); + insert.run('charles', bcrypt.hashSync('360partners!', 10), 'Charles', 'client'); +} + +const domainCount = db.prepare('SELECT COUNT(*) as count FROM domains').get().count; +if (domainCount === 0) { + const insert = db.prepare('INSERT INTO domains (name, company_name, parent_id, sort_order) VALUES (?, ?, ?, ?)'); + const result = insert.run('360-partners.fr', '360 Partners', null, 0); + const pid = result.lastInsertRowid; + insert.run('monsieur-edgar.fr', 'Monsieur Edgar', pid, 1); + insert.run('monsieur-edgar.com', 'Monsieur Edgar', pid, 2); + insert.run('qwarter.fr', 'Qwarter', pid, 3); + insert.run('agenceredo.fr', 'Agence Redo', pid, 4); + insert.run('hub-grade.com', 'HUB-GRADE', pid, 5); + insert.run('momentz.online', 'MOMENTZ', pid, 6); + insert.run('momentz.paris', 'MOMENTZ', pid, 7); +} + +module.exports = db; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..2c31350 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,80 @@ +const express = require('express'); +const cors = require('cors'); +const bcrypt = require('bcryptjs'); +const db = require('./db'); +const { generateToken, authMiddleware, adminOnly } = require('./auth'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.post('/api/login', (req, res) => { + const { username, password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Identifiants incorrects' }); + } + const token = generateToken(user); + res.json({ token, user: { id: user.id, name: user.name, role: user.role } }); +}); + +app.get('/api/domains', authMiddleware, (req, res) => { + const domains = db.prepare('SELECT * FROM domains ORDER BY sort_order, id').all(); + const parent = domains.find(d => !d.parent_id); + const children = domains.filter(d => d.parent_id); + if (req.user.role === 'client') { + domains.forEach(d => { delete d.monthly_price; }); + } + res.json({ parent, children }); +}); + +app.get('/api/domains/:id', authMiddleware, (req, res) => { + const domain = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); + if (!domain) return res.status(404).json({ error: 'Domaine non trouve' }); + if (req.user.role === 'client') delete domain.monthly_price; + res.json(domain); +}); + +app.put('/api/domains/:id', authMiddleware, (req, res) => { + const domain = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); + if (!domain) return res.status(404).json({ error: 'Domaine non trouve' }); + + const fields = [ + 'has_cloud', 'cloud_details', 'has_website', 'website_wordpress', + 'website_managed_by', 'website_details', 'has_emails', 'email_count', + 'email_addresses', 'has_team', 'team_members', 'notes' + ]; + if (req.user.role === 'admin') fields.push('monthly_price'); + + const updates = {}; + fields.forEach(f => { if (req.body[f] !== undefined) updates[f] = req.body[f]; }); + updates.updated_at = new Date().toISOString(); + + const setClause = Object.keys(updates).map(k => k + ' = ?').join(', '); + const values = Object.values(updates); + values.push(req.params.id); + db.prepare('UPDATE domains SET ' + setClause + ' WHERE id = ?').run(...values); + + const updated = db.prepare('SELECT * FROM domains WHERE id = ?').get(req.params.id); + if (req.user.role === 'client') delete updated.monthly_price; + res.json(updated); +}); + +app.post('/api/domains', authMiddleware, adminOnly, (req, res) => { + const { name, company_name, parent_id } = req.body; + if (!name || !company_name) return res.status(400).json({ error: 'Nom requis' }); + const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as m FROM domains').get().m; + const result = db.prepare('INSERT INTO domains (name, company_name, parent_id, sort_order) VALUES (?, ?, ?, ?)') + .run(name, company_name, parent_id || null, maxOrder + 1); + res.json(db.prepare('SELECT * FROM domains WHERE id = ?').get(result.lastInsertRowid)); +}); + +app.delete('/api/domains/:id', authMiddleware, adminOnly, (req, res) => { + db.prepare('DELETE FROM domains WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +app.get('/api/health', (req, res) => res.json({ ok: true })); + +const PORT = process.env.PORT || 3008; +app.listen(PORT, '0.0.0.0', () => console.log('Charles backend on port ' + PORT)); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99b63f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: charles-backend + restart: unless-stopped + volumes: + - ./data:/app/data + networks: + - charles-net + + frontend: + build: ./frontend + container_name: charles-frontend + restart: unless-stopped + ports: + - "3008:80" + depends_on: + - backend + networks: + - charles-net + +networks: + charles-net: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c62d91c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..86b5932 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Charles - SecourIT + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..81e0b88 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api { + proxy_pass http://charles-backend:3008; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6e20b66 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "charles-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "vite": "^5.4.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..82731cb --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..d353fb4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import Login from './components/Login' +import Layout from './components/Layout' +import Dashboard from './components/Dashboard' +import DomainForm from './components/DomainForm' + +function App() { + const [token, setToken] = useState(localStorage.getItem('charles_token')) + const [user, setUser] = useState(() => { try { return JSON.parse(localStorage.getItem('charles_user')) } catch { return null } }) + + const handleLogin = (t, u) => { + localStorage.setItem('charles_token', t) + localStorage.setItem('charles_user', JSON.stringify(u)) + setToken(t) + setUser(u) + } + const handleLogout = () => { + localStorage.removeItem('charles_token') + localStorage.removeItem('charles_user') + setToken(null) + setUser(null) + } + + if (!token) return + + return ( + + + + } /> + } /> + } /> + + + + ) +} +export default App diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..b12a143 --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' + +export default function Dashboard({ token, user }) { + const [domains, setDomains] = useState({ parent: null, children: [] }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/domains', { headers: { Authorization: 'Bearer ' + token } }) + .then(r => r.json()).then(d => { setDomains(d); setLoading(false) }) + }, [token]) + + if (loading) return
Chargement...
+ + const count = (d) => [d.has_cloud, d.has_website, d.has_emails, d.has_team].filter(Boolean).length + const color = (n) => n === 0 ? 'bg-slate-600' : n < 3 ? 'bg-amber-500' : 'bg-emerald-500' + + return ( +
+
+

Domaines & Services

+

+ {user.role === 'admin' ? 'Gerez les besoins de Charles pour chaque societe.' : 'Renseignez vos besoins pour chaque domaine.'} +

+
+ + {domains.parent && ( +
+
+
+ + + +
+
+

{domains.parent.company_name}

+

{domains.parent.name} — Entreprise maitre

+
+
+
+ )} + +
+ {domains.children.map(d => { + const n = count(d) + const tags = [] + if (d.has_cloud) tags.push(Cloud) + if (d.has_website) tags.push(Site{d.website_wordpress ? ' WP' : ''}) + if (d.has_emails) tags.push({d.email_count} mail{d.email_count > 1 ? 's' : ''}) + if (d.has_team) { const m = JSON.parse(d.team_members || '[]'); tags.push({m.length} user{m.length > 1 ? 's' : ''}) } + if (n === 0) tags.push(Aucun besoin renseigne) + + return ( + +
+
+

{d.company_name}

+

{d.name}

+
+
+
+
{tags}
+ {user.role === 'admin' && d.monthly_price > 0 && ( +
+ {d.monthly_price} EUR/mois +
+ )} + + ) + })} +
+ + {user.role === 'admin' && ( +
+

Resume

+
+

{domains.children.length}

Domaines

+

{domains.children.filter(d => d.has_cloud).length}

Cloud

+

{domains.children.filter(d => d.has_website).length}

Sites web

+

{domains.children.filter(d => d.has_emails).length}

Avec mails

+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/DomainForm.jsx b/frontend/src/components/DomainForm.jsx new file mode 100644 index 0000000..5511213 --- /dev/null +++ b/frontend/src/components/DomainForm.jsx @@ -0,0 +1,177 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' + +export default function DomainForm({ token, user }) { + const { id } = useParams() + const navigate = useNavigate() + const [d, setD] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + + useEffect(() => { + fetch('/api/domains/' + id, { headers: { Authorization: 'Bearer ' + token } }) + .then(r => r.json()).then(data => { setD(data); setLoading(false) }) + }, [id, token]) + + const u = (field, val) => { setD(p => ({ ...p, [field]: val })); setSaved(false) } + + const save = async () => { + setSaving(true) + const res = await fetch('/api/domains/' + id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, + body: JSON.stringify(d) + }) + setD(await res.json()) + setSaving(false) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + + const addEmail = () => { const e = JSON.parse(d.email_addresses || '[]'); e.push(''); u('email_addresses', JSON.stringify(e)) } + const updateEmail = (i, v) => { const e = JSON.parse(d.email_addresses || '[]'); e[i] = v; u('email_addresses', JSON.stringify(e)) } + const removeEmail = (i) => { const e = JSON.parse(d.email_addresses || '[]'); e.splice(i, 1); u('email_addresses', JSON.stringify(e)); u('email_count', e.length) } + const addMember = () => { const m = JSON.parse(d.team_members || '[]'); m.push({ first_name: '', last_name: '' }); u('team_members', JSON.stringify(m)) } + const updateMember = (i, f, v) => { const m = JSON.parse(d.team_members || '[]'); m[i][f] = v; u('team_members', JSON.stringify(m)) } + const removeMember = (i) => { const m = JSON.parse(d.team_members || '[]'); m.splice(i, 1); u('team_members', JSON.stringify(m)) } + + if (loading) return
Chargement...
+ if (!d) return
Domaine non trouve
+ + const Toggle = ({ checked, onChange, label }) => ( +
+ {label} + +
+ ) + + const Section = ({ title, color, children }) => ( +
+

{title}

+ {children} +
+ ) + + const Input = ({ label, value, onChange, placeholder, type = 'text' }) => ( +
+ + onChange(e.target.value)} placeholder={placeholder} + className="w-full px-3 py-2.5 bg-slate-900/50 border border-slate-600 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> +
+ ) + + return ( +
+ + +
+
+

{d.company_name}

+

{d.name}

+
+ +
+ +
+
+ u('has_cloud', v ? 1 : 0)} label="Besoin d'un cloud" /> + {d.has_cloud ? ( +