Dans cette deuxième partie de projet (partie 1 ici) nous allons continuer l'implémentation de notre plateforme de mise en avant des offres commerciales.
Les différents éléments mise en place dans cette partie :
- Authentification avec un couple unique login/password
- Nouveau routeur avec Joi router afin de pouvoir gérer aisément la validation des formulaires
- CRUD des offres avec stockage sur Firestore (la base NoSQL de Firebase)
- Système de messages flash afin de rendre l'utilisation plus intuitive
Il va donc s'agir dans un premier temps d'installer les différentes dépendances nécessaires à l'implémentation de ces nouvelles features ainsi que de les configurer grâce aux variables d’environnement.
Les sources de ce chapitre d'implémentation : https://github.com/Zenicheck/blog/tree/master/active-offer-koa-v2
Configuration de la suite projet :
$ cat package.json | tail -n 16
"dependencies": {
"bcrypt": "^4.0.1",
"dotenv": "^8.2.0",
"firebase-admin": "^8.10.0",
"koa": "^2.11.0",
"koa-better-flash": "0.0.4",
"koa-bodyparser": "^4.3.0",
"koa-ejs": "^4.3.0",
"koa-generic-session": "^2.0.4",
"koa-joi-router": "^6.0.2",
"koa-logger": "^3.2.1",
"koa-passport": "^4.1.3",
"koa-static": "^5.0.0",
"passport-local": "^1.0.0"
}
}
Il est important de vérifier que les dépendances sont bien présentes puis de les installer :
$ npm i
Nous allons ensuite configurer les différentes variables d'environnement :
$ cat .env
USERNAME=contact@yanncarlen.com
PASSWORD=YOUR-PASSWORD
GEN_SALT=10
FIREBASE_HOST=https://YOUR-HOST.firebaseio.com
KEYS_1=YOUR-KEY_1
KEYS_2=YOUR-KEY_2
Il faut donc remplacer les différentes variables par des valeurs propres à votre application :
- USERNAME, votre adresse mail
- YOUR-PASSWORD, se référer à la première partie pour voir comment le générer
- YOUR-HOST, vous trouverez cette information dans la configuration de votre projet Firebase
- YOUR-KEY_1, YOUR-KEY_2, vous pouvez générer des hash ici en spécifiant un digest de 1 par exemple afin d'obtenir un hash de taille convenable
Nous allons ensuite créer les différentes vues du CRUD :
La vue contenant la datatable des offres :
<!-- adm-offer.html.ejs -->
<%- include('title', { title, extra : '<a href="/adm/offer/add" class="d-none d-sm-inline-block btn btn-sm btn-primary shadow-sm"><i class="fas fa-ad fa-sm text-white-50"></i> Ajouter une offre</a>' }); %>
<!-- Content Row -->
<div class="row card shadow">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered" id="offerTable" width="100%" cellspacing="0">
<thead>
<tr>
<th>Titre</th>
<th>Lien</th>
<th>Mise en avant</th>
<th>Action</th>
</tr>
</thead>
<tfoot>
<tr>
<th>Titre</th>
<th>Lien</th>
<th>Mise en avant</th>
<th>Action</th>
</tr>
</tfoot>
<tbody>
<% offers.forEach(function (e) { %>
<tr>
<td><%- e.offerTitle %></td>
<td><%- e.offerLink %></td>
<td><% if (!e.offerPublished) { %>
<span class="btn btn-info">Non</span>
<% } else { %>
<span class="btn btn-success">Oui</span>
<% } %>
</td>
<!-- Add label -->
<td>
<a href="/adm/offer/edit/<%= e.id %>" class="btn btn-primary"><span class="text"
style="color:azure;">Editer</span></a>
<% if (e.offerPublished) { %>
<a href="/adm/offer/publish/<%= e.id %>" class="btn btn-danger"><span class="text"
style="color:azure;">Dépublier</span></a>
<% } else { %>
<a href="/adm/offer/publish/<%= e.id %>" class="btn btn-success"><span class="text"
style="color:azure;">Publier</span></a>
<% } %>
<a href="/adm/offer/delete/<%= e.id %>" class="btn btn-warning"><span class="text"
style="color:azure;">Supprimer</span></a>
<!-- Add link -->
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
Le template permettant d'effectuer les différentes actions sur une offre en fonction de l'action passée :
<!-- adm-offer-action.html.ejs -->
<%- include('title', { title }); %>
<!-- Content Row -->
<div class="row card shadow col-sm-3">
<div class="card-body">
<%- include('error'); %>
<% if (locals.delete) { %>
<form class="user" action="/adm/offer/unstore" method="POST">
<% if (locals.offer) { %>
<input type="hidden" name="offerId" value="<%= offer.id %>">
<% } %>
<p><%- offer.offerTitle %></p>
<button type="submit" class="btn btn-primary btn-user btn-block">
Supprimer l'offre
</button>
<hr>
</form>
<a href="/adm/offer" class="btn btn-warning">Non</a>
<% } else if (locals.publish) { %>
<form class="user" action="/adm/offer/release" method="POST">
<% if (locals.offer) { %>
<input type="hidden" name="offerId" value="<%= offer.id %>">
<% } %>
<p><%- offer.offerTitle %></p>
<button type="submit" class="btn btn-primary btn-user btn-block">
<% if (!offer.offerPublished) { %>Publier l'offre<% } else { %>Dépublier l'offre<% } %>
</button>
<hr>
</form>
<a href="/adm/offer" class="btn btn-warning">Non</a>
<% } else { %>
<form class="user" action="/adm/offer/store" method="POST">
<% if (locals.offer) { %>
<input type="hidden" name="offerId" value="<%= offer.id %>">
<% } else if (ctx.request.body.offerId) { %>
<input type="hidden" name="offerId" value="<%= offerId %>">
<% } %>
<div class="form-group row">
<input type="text" class="form-control form-control-user" id="offerTitle" name="offerTitle"
placeholder="Texte de l'annonce"
value="<% if (ctx.request.body.offerTitle) { %><%= ctx.request.body.offerTitle %><% }%>" required>
</div>
<div class="form-group row">
<input type="url" class="form-control form-control-user" id="offerLink" placeholder="Lien de l'annonce"
name="offerLink"
value="<% if (ctx.request.body.offerLink) { %><%= ctx.request.body.offerLink %><% }%>" required>
</div>
<button type="submit" class="btn btn-primary btn-user btn-block">
Ajouter l'offre
</button>
<hr>
</form>
<% } %>
</div>
</div>
Les helpers pour la gestion des erreurs du formulaire et de la gestion du titre de la page :
<!-- error.html.ejs -->
<% if (locals.errors) { %>
<div class="card bg-danger text-white shadow" style="margin-bottom: 20px;">
<div class="card-body">
<%- locals.errors.msg %>
</div>
</div>
<% } %>
<!-- title.html.ejs -->
<!-- Page Heading -->
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800"><%= title %></h1>
<% if (locals.extra) { %>
<%- extra %>
<% } %>
</div>
Ainsi que la vue pour l'authentification :
<!DOCTYPE html>
<html lang="fr">
<!-- so.html.ejs -->
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Zenicheck - Gestion de vos offres commerciales">
<meta name="author" content="Yann Carlen - Zenicheck">
<title><%= `${ctx.title} - ${title}` %></title>
<!-- Custom fonts for this template-->
<link href="/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
<link
href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i"
rel="stylesheet">
<!-- Custom styles for this template-->
<link href="/css/sb-admin-2.min.css" rel="stylesheet">
</head>
<body class="bg-gradient-primary">
<div class="container">
<!-- Outer Row -->
<div class="row justify-content-center">
<div class="col-xl-10 col-lg-12 col-md-9">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
<div class="col-lg-6">
<div class="p-5">
<div class="text-center">
<h1 class="h4 text-gray-900 mb-4">Welcome Back!</h1>
</div>
<%- include('error'); %>
<form class="user" action="/so" method="POST">
<div class="form-group">
<input type="email" class="form-control form-control-user" id="soEmail"
name="soEmail" placeholder="Email" required>
</div>
<div class="form-group">
<input type="password" class="form-control form-control-user"
id="soPassword" name="soPassword" placeholder="Mot de passe" required>
</div>
<button type="submit" class="btn btn-primary btn-user btn-block">
Se connecter
</button>
</form>
<hr>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap core JavaScript-->
<script src="/vendor/jquery/jquery.min.js"></script>
<script src="/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Core plugin JavaScript-->
<script src="/vendor/jquery-easing/jquery.easing.min.js"></script>
<!-- Custom scripts for all pages-->
<script src="/js/sb-admin-2.min.js"></script>
</body>
</html>
Également des modifications sur le template :
- Gestion du lien actif de navigation
- Lien d'ajout pour le CRUD
- Gestion des messages flash
- Gestion du nom d'utilisateur récupéré dans sa session
- Gestion du bouton de déconnexion
- Ajout des dépendances js
$ diff -u active-offer-koa-v1/views/template.html.ejs active-offer-koa-v2/views/template.html.ejs
--- active-offer-koa-v1/views/template.html.ejs 2020-04-16 04:40:50.685382008 +0200
+++ active-offer-koa-v2/views/template.html.ejs 2020-04-20 06:28:28.461065688 +0200
@@ -9,7 +9,7 @@
<meta name="description" content="Zenicheck - Gestion de vos offres commerciales">
<meta name="author" content="Yann Carlen - Zenicheck">
- <title><%= `${ctx.title}-${title}` %></title>
+ <title><%= `${ctx.title} - ${title}` %></title>
<!-- Custom fonts for this template-->
<link href="/vendor/fontawesome-free/css/all.min.css" rel="stylesheet" type="text/css">
@@ -19,6 +19,11 @@
<!-- Custom styles for this template-->
<link href="/css/sb-admin-2.min.css" rel="stylesheet">
+ <style>
+ .table-responsive {
+ overflow: inherit;
+ }
+ </style>
</head>
@@ -41,9 +46,8 @@
<!-- Divider -->
<hr class="sidebar-divider my-0">
-
<!-- Nav Item - Dashboard -->
- <li class="nav-item active">
+ <li class="nav-item <% if (ctx.request.url.split('?')[0] == '/adm') { %>active<% } %>">
<a class="nav-link" href="/adm">
<i class="fas fa-fw fa-tachometer-alt"></i>
<span>Dashboard</span></a>
@@ -58,11 +62,16 @@
</div>
<!-- Nav Item - Offers -->
- <li class="nav-item">
+ <li class="nav-item <% if (ctx.request.url.indexOf('/adm/offer') != -1) { %>active<% } %>">
<a class="nav-link" href="/adm/offer">
<i class="fas fa-fw fa-ad"></i>
<span>offres</span></a>
</li>
+ <li class="nav-item <% if (ctx.request.url.indexOf('/adm/offer/add') != -1) { %>active<% } %>">
+ <a class="nav-link" href="/adm/offer/add">
+ <i class="fas fa-fw fa-ad"></i>
+ <span>ajouter une offre</span></a>
+ </li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
@@ -95,7 +104,7 @@
<li class="nav-item dropdown no-arrow">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
- <span class="mr-2 d-none d-lg-inline text-gray-600 small">Admin</span>
+ <span class="mr-2 d-none d-lg-inline text-gray-600 small"><%- ctx.state.user %></span>
</a>
<!-- Dropdown - User Information -->
<div class="dropdown-menu dropdown-menu-right shadow animated--grow-in"
@@ -115,6 +124,16 @@
<!-- Begin Page Content -->
<div class="container-fluid">
<%- body %>
+ <% var flash = ctx.flash('info') %>
+ <% flash.forEach(function (e) { %>
+ <div class="col-lg-3 mb-3 flash" style="margin-top: 50px;">
+ <div class="card bg-success text-white shadow">
+ <div class="card-body">
+ <%- e %>
+ </div>
+ </div>
+ </div>
+ <% }) %>
</div>
<!-- /.container-fluid -->
@@ -158,7 +177,7 @@
<div class="modal-body">Voulez-vous vraiment vous déconnecter ?</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">Annuler</button>
- <a class="btn btn-primary" href="/adm/logout">Se déconnecter</a>
+ <a class="btn btn-primary" href="/so/logout">Se déconnecter</a>
</div>
</div>
</div>
@@ -176,6 +195,12 @@
<!-- Page level plugins -->
<script src="/vendor/chart.js/Chart.min.js"></script>
+ <script src="/vendor/datatables/jquery.dataTables.min.js"></script>
+ <script src="/vendor/datatables/dataTables.bootstrap4.min.js"></script>
+
+ <!-- Custom scripts for all pages-->
+ <script src="/js/app.js"></script>
+
</body>
</html>
\ No newline at end of file
Le script de gestion des messages flash et de la datatable :
/* public/js/app.js */
// Call the dataTables jQuery plugin
$(document).ready(function () {
const offerTable = $('#offerTable')
const flash = $('.flash')
offerTable.length && offerTable.DataTable();
flash.length && flash.each(function () {
$(this).fadeOut(2999);
});
});
Ce qui va donner les visuels suivants (une fois le router raccordé) :
CRUD :
Authentification :
Ajout/Édition :
Publication/Dépublication :
Intervient maintenant l'injection des différentes dépendances dans le système de middleware de Koa :
/* app.mjs */
import path from 'path'
import Koa from 'koa'
import ejs from 'koa-ejs'
import logger from 'koa-logger'
import _static from 'koa-static'
import session from 'koa-generic-session'
import flash from 'koa-better-flash'
import bodyparser from 'koa-bodyparser'
import passport from 'koa-passport'
import admin from 'firebase-admin'
import dotenv from 'dotenv'
import indexRouter from './routes/index.mjs'
import admRouter from './routes/adm.mjs'
import soRouter from './routes/so.mjs'
const __dirname = path.resolve()
const config = dotenv.config().parsed
import serviceAccount from './offer-firebase.json'
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: config.FIREBASE_HOST
})
const db = admin.firestore()
const app = new Koa()
app.context.db = db
app.context.config = config
app.context.title = 'Gestion de vos offres commerciales'
app.keys = [config.KEYS_1, config.KEYS_2]
ejs(app, {
root: path.join(__dirname, 'views'),
layout: 'template',
viewExt: 'html.ejs',
cache: false, //TODO turn on for production
debug: false, //TODO turn off for profuction
})
app.use(bodyparser())
app.use(session({
prefix: '__sess:',
key: '__sid'
}))
import './helpers/so.mjs'
app.use(passport.initialize())
app.use(passport.session())
app.use(logger())
app.use(flash())
app.use(_static(path.join(__dirname, 'public')))
app.use(indexRouter.middleware())
app.use(admRouter.middleware())
app.use(soRouter.middleware())
export default app
Arrive ensuite l'implémentation de l'authentification avec Passport et Koa :
Implémentation de la stratégie d'authentification locale :
/* helpers/so.mjs */
import passport from 'koa-passport'
import bcrypt from 'bcrypt'
import strategies from 'passport-local'
import dotenv from 'dotenv'
const config = dotenv.config().parsed
passport.serializeUser((user, done) => done(null, user))
passport.deserializeUser((id, done) => done(null, id))
const LocalStrategy = strategies.Strategy
const local = new LocalStrategy({
usernameField: 'soEmail',
passwordField: 'soPassword'
}, async (username, password, done) => config.USERNAME === username ?
bcrypt.compare(password, config.PASSWORD, (_, response) => done(null, response ? config.USERNAME : false))
:
done(null, false))
passport.use(local)
Le middleware d'autorisation :
/* helpers/middleware.mjs */
const authenticated = () => (ctx, next) => ctx.isAuthenticated() ? next() : ctx.redirect('/so')
export { authenticated }
Et enfin le routeur gérant les différentes routes propres à l'authentification :
/* routes/so.mjs */
import Router from 'koa-joi-router'
import passport from 'koa-passport'
const Joi = Router.Joi
const router = Router()
const joiDefault = {
type: 'form',
continueOnError: true
}
router.prefix('/so')
router.get('/', async ctx => {
if (ctx.isAuthenticated()) {
await ctx.flash('info', 'Vous êtes bien connecté !')
return await ctx.redirect('/adm')
}
await ctx.render('so', { ctx, title: 'Se connecter', layout: false })
})
router.post('/', {
validate: {
body: {
soEmail: Joi.string().email().required()
.error(new Error('Veuillez entrez votre email')),
soPassword: Joi.string().required()
.error(new Error('Veuillez entrez votre mot de passe'))
},
...joiDefault
}
}, async ctx => {
if (ctx.isAuthenticated()) {
await ctx.flash('info', 'Vous êtes bien connecté !')
return await ctx.redirect('/adm')
}
if (ctx.invalid) {
return await ctx.render('so', { ctx, title: 'Se connecter', errors: ctx.invalid.body, layout: false })
}
return passport.authenticate('local', async (err, user) => {
console.log(user)
if (user === false) {
ctx.status = 401
const errors = { msg: 'Email ou Mot de Passe invalide' }
return await ctx.render('so', { ctx, title: 'Se connecter', errors, layout: false })
}
ctx.login(user)
await ctx.flash('info', 'Vous êtes bien connecté !')
return await ctx.redirect('/adm')
})(ctx)
})
router.get('/logout', async ctx => {
await ctx.logout()
await ctx.redirect('/so')
})
export default router
Et enfin l'implémentation du CRUD des offres avec Firestore comme outils de persistance :
La couche d'accès aux données :
/* helpers/offerRepository.mjs */
const tableName = 'offer'
const schema = ({ offerTitle, offerLink, offerPublished }) =>
({ offerTitle, offerLink, offerPublished: offerPublished == undefined ? false : offerPublished })
const saveOffer = db => offer => db.collection(tableName).doc().set(schema(offer))
const editOffer = db => id => offer => db.collection(tableName).doc(id).set(schema(offer))
const deleteOffer = db => id => (db.collection(tableName).doc(id)).delete()
const getOffer = db => (db.collection(tableName).get()).then(e => e.docs.map(doc => ({ id: doc.id, ...doc.data()})))
const findOffer = db => id => (db.collection(tableName).doc(id).get()).then(e => [ e.exists, e ])
export { saveOffer, getOffer, findOffer, editOffer, deleteOffer }
Le routeur associé :
/* routes/adm.mjs */
import Router from 'koa-joi-router'
import { authenticated } from '../helpers/middleware.mjs'
import { saveOffer, getOffer, findOffer, editOffer, deleteOffer } from '../helpers/offerRepository.mjs'
const Joi = Router.Joi
const router = Router()
const joiDefault = {
type: 'form',
continueOnError: true
}
router.prefix('/adm')
router.param('id', async (id, ctx, next) => {
const [offer, e] = await findOffer(ctx.db)(id)
if (!offer) return ctx.status = 404
ctx.offer = { id: e.id, ...e.data() }
ctx.request.body = ctx.offer
await next()
})
router.get('/', authenticated(), async ctx => await ctx.render('adm', { ctx, title: 'Adm' }))
router.get('/offer', authenticated(), async ctx => {
await ctx.render('adm-offer', { ctx, title: 'Listes des offres', offers: await getOffer(ctx.db) })
})
router.get('/offer/add', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Ajouter une offre' }))
router.get('/offer/edit/:id', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Editer l\'offre', offer: ctx.offer }))
router.get('/offer/delete/:id', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Editer l\'offre', offer: ctx.offer, delete: true }))
router.get('/offer/publish/:id', authenticated(), async ctx => await ctx.render('adm-offer-action', { ctx, title: 'Publier l\'offre', offer: ctx.offer, publish: true }))
router.post('/offer/store', {
validate: {
body: {
offerTitle: Joi.string().max(255).min(4).required()
.error(new Error('Veuillez entrer une titre entre 4 et 255 charactères')),
offerLink: Joi.string().uri().required()
.error(new Error('Veuillez entrer une lien')),
offerId: Joi.string()
},
...joiDefault
}
}, authenticated(), async ctx => {
if (ctx.request.body.offerId) {
const [valid, e] = await findOffer(ctx.db)(ctx.request.body.offerId)
if (!valid) return ctx.status = 404
const offer = { id: e.id, ...e.data() }
if (ctx.invalid) {
return await ctx.render('adm-offer-action', { ctx, errors: ctx.invalid.body, title: 'Editer une offre', offer })
}
await editOffer(ctx.db)(offer.id)({ ...ctx.request.body, offerPublished: offer.offerPublished })
ctx.flash('info', 'Offre modifiée')
return ctx.redirect('/adm/offer')
}
if (ctx.invalid) {
return await ctx.render('adm-offer-action', { ctx, errors: ctx.invalid.body, title: 'Ajouter une offre' })
}
await saveOffer(ctx.db)(ctx.request.body)
ctx.flash('info', 'Offre créée')
ctx.redirect('/adm/offer')
})
router.post('/offer/unstore', {
validate: {
body: {
offerId: Joi.string().required()
},
...joiDefault
}
}, authenticated(), async ctx => {
if (ctx.invalid) {
return ctx.redirect('/adm/offer')
}
const [valid, _] = await findOffer(ctx.db)(ctx.request.body.offerId)
if (!valid) return ctx.status = 404
await deleteOffer(ctx.db)(ctx.request.body.offerId)
ctx.flash('info', 'Offre supprimée')
ctx.redirect('/adm/offer')
})
router.post('/offer/release', {
validate: {
body: {
offerId: Joi.string().required()
},
...joiDefault
}
}, authenticated(), async ctx => {
if (ctx.invalid) {
return ctx.redirect('/adm/offer')
}
const [valid, e] = await findOffer(ctx.db)(ctx.request.body.offerId)
if (!valid) return ctx.status = 404
const offer = { ...e.data() }
await editOffer(ctx.db)(ctx.request.body.offerId)({ ...offer, offerPublished: !offer.offerPublished })
if (offer.offerPublished) {
ctx.flash('info', 'Offre dépubliée')
} else {
ctx.flash('info', 'Offre publiée')
}
ctx.redirect('/adm/offer')
})
export default router
Pour rappel la commande pour lancer le projet durant le développement est la suivante :
$ PORT=8900 npx nodemon --experimental-modules --experimental-json-modules bin/www.mjs --watch routes --watch views --watch helpers --watch app.mjs
Les sources de ce chapitre d'implémentation : https://github.com/Zenicheck/blog/tree/master/active-offer-koa-v2
Si vous cherchez un développeur Node.js afin de réaliser votre site ou de vous accompagner sur une autre problématique n'hésitez pas à me contacter !
Dans la partie prochaine interviendra l'implémentation des fonctionnalités suivantes :
- API REST pour l'accès public des offres
- Exemple d'implémentation d'une iframe avec personnalisation du visuel pour l'API REST
- Exemple d'utilisation de l'API sur un site de référence
- Déploiement de la plateforme avec Docker