diff --git a/.editorconfig b/.editorconfig index 3fd4319..8eca03d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ end_of_line = lf [*.html] indent_style = space -indent_size = 2 +indent_size = 4 [*.{js,json,yml}] indent_style = space diff --git a/.gitignore b/.gitignore index 4b49537..79165cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -# Created by https://www.toptal.com/developers/gitignore/api/node,code,linux,vim,windows,macos -# Edit at https://www.toptal.com/developers/gitignore?templates=node,code,linux,vim,windows,macos +# Created by https://www.toptal.com/developers/gitignore/api/vim,node,code,macos,linux,windows,jetbrains+all +# Edit at https://www.toptal.com/developers/gitignore?templates=vim,node,code,macos,linux,windows,jetbrains+all ### Code ### .vscode/* @@ -10,6 +10,95 @@ !.vscode/extensions.json *.code-workspace +### JetBrains+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + ### Linux ### *~ @@ -210,4 +299,4 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/node,code,linux,vim,windows,macos +# End of https://www.toptal.com/developers/gitignore/api/vim,node,code,macos,linux,windows,jetbrains+all diff --git a/package-lock.json b/package-lock.json index d5c57da..cbbcbbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3112,6 +3112,11 @@ "randomfill": "^1.0.3" } }, + "crypto-js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", + "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==" + }, "css-blank-pseudo": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz", @@ -6180,6 +6185,11 @@ "yallist": "^2.1.2" } }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=" + }, "magic-string": { "version": "0.22.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", @@ -9516,6 +9526,15 @@ "xmlchars": "^2.1.1" } }, + "secure-ls": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/secure-ls/-/secure-ls-1.2.6.tgz", + "integrity": "sha512-g8vUSKl6elSfyAUHodybnNkuZW+mUYEOWj4SZIDg+xoQ1dq5ddktBoOFrtxQBUl88ZyAJOtGWQ1PRaOxkTAuZQ==", + "requires": { + "crypto-js": "^3.1.6", + "lz-string": "^1.4.4" + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", diff --git a/package.json b/package.json index de35f8d..f000d3c 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "@fortawesome/free-solid-svg-icons": "^5.15.1", "bootstrap": "^4.5.3", "pdf-lib": "^1.11.2", - "qrcode": "^1.4.4" + "qrcode": "^1.4.4", + "secure-ls": "^1.2.6" }, "browserslist": [ "last 5 versions" diff --git a/src/css/main.css b/src/css/main.css index 5601120..d37457a 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -154,6 +154,10 @@ p { transform: translateY(-2px); } +#form-profile #formgroup-storedata { + user-select: none; +} + @media (prefers-color-scheme: dark) { #form-profile .form-radio-label .form-check-label { color: #ddd; @@ -563,7 +567,7 @@ input:valid+span:after { } } -#snackbar { +#snackbar, #snackbar-cleardata { min-width: 250px; color: #fff; text-align: center; @@ -580,7 +584,7 @@ input:valid+span:after { transition: all 0.5s ease-in-out; } -#snackbar.show { +#snackbar.show, #snackbar-cleardata { opacity: 1; } @@ -645,4 +649,4 @@ input:valid+span:after { .fieldset-error { border: 3px solid red; -} \ No newline at end of file +} diff --git a/src/index.html b/src/index.html index e0869e0..0eebcba 100644 --- a/src/index.html +++ b/src/index.html @@ -1,391 +1,408 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Attestation de déplacement dérogatoire - - - -
- - -
-
-

Remplissez en ligne votre déclaration numérique:

-

Tous les champs sont obligatoires.

- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- -
- - -
-

-
- -
- Choisissez un motif de déplacement - -

- Je certifie que mon déplacement est lié au motif suivant (cocher la case) autorisé en application des mesures générales nécessaires pour faire face à l'épidémie de Covid19 dans le cadre de l'état d'urgence sanitaire - [1] : -

-
- - -
- -
- - -
- -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

- -

- -
- L'attestation est téléchargée sur votre appareil. -
-
-
- -
-

- - [1] Les personnes souhaitant bénéficier de l'une de ces exceptions doivent se munir s'il y a lieu, lors de leurs déplacements hors de leur domicile, d'un document leur permettant de justifier que le déplacement considéré entre dans le champ de l'une de ces exceptions. -
-

-

- Le code source de ce service est consultable sur GitHub. -

-

- Ministère de l'Intérieur - DNUM - SDIT -

- - - logo dnum - -
-
- - -
- Une nouvelle version est disponible. Cliquer sur le bouton pour l'obtenir. -

- -

-
- - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Attestation de déplacement dérogatoire + + + +
+ + +
+
+

Remplissez en ligne votre déclaration numérique:

+

Tous les champs sont obligatoires.

+ +
+ + Effacer les données du formulaire ? +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ +
+ + +
+

+
+ +
+ Choisissez un motif de déplacement + +

+ Je certifie que mon déplacement est lié au motif suivant (cocher la case) autorisé en application des mesures générales nécessaires pour faire face à l'épidémie de Covid19 dans le cadre de l'état d'urgence sanitaire + [1] : +

+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ +

+ +

+ + +

+ +
+ L'attestation est téléchargée sur votre appareil. +
+
+ Les données du formulaire ont été effacées. +
+
+
+ +
+

+ + [1] Les personnes souhaitant bénéficier de l'une de ces exceptions doivent se munir s'il y a lieu, lors de leurs déplacements hors de leur domicile, d'un document leur permettant de justifier que le déplacement considéré entre dans le champ de l'une de ces exceptions. +
+

+

+ Le code source de ce service est consultable sur GitHub. +

+

+ Ministère de l'Intérieur - DNUM - SDIT +

+ + + logo dnum + +
+
+ + +
+ Une nouvelle version est disponible. Cliquer sur le bouton pour l'obtenir. +

+ +

+
+ + + + diff --git a/src/js/form-util.js b/src/js/form-util.js index a142c37..409e451 100644 --- a/src/js/form-util.js +++ b/src/js/form-util.js @@ -1,156 +1,228 @@ -import { $, $$, downloadBlob } from './dom-utils' -import { addSlash, getFormattedDate } from './util' -import pdfBase from '../certificate.pdf' -import { generatePdf } from './pdf-util' - -const formInputs = $$('#form-profile input') -const snackbar = $('#snackbar') -const reasonInputs = [...$$('input[name="field-reason"]')] -const reasonFieldset = $('#reason-fieldset') -const reasonAlert = reasonFieldset.querySelector('.msg-alert') -const releaseDateInput = $('#field-datesortie') - -const conditions = { - '#field-firstname': { - length: 1, - }, - '#field-lastname': { - length: 1, - }, - '#field-birthday': { - pattern: /^([0][1-9]|[1-2][0-9]|30|31)\/([0][1-9]|10|11|12)\/(19[0-9][0-9]|20[0-1][0-9]|2020)/g, - }, - '#field-lieunaissance': { - length: 1, - }, - '#field-address': { - length: 1, - }, - '#field-town': { - length: 1, - }, - '#field-zipcode': { - pattern: /\d{5}/g, - }, - '#field-datesortie': { - pattern: /\d{4}-\d{2}-\d{2}/g, - }, - '#field-heuresortie': { - pattern: /\d{2}:\d{2}/g, - }, -} - -function validateAriaFields () { - return Object.keys(conditions) - .map((field) => { - const fieldData = conditions[field] - const pattern = fieldData.pattern - const length = fieldData.length - const isInvalidPattern = pattern && !$(field).value.match(pattern) - const isInvalidLength = length && !$(field).value.length - - const isInvalid = !!(isInvalidPattern || isInvalidLength) - - $(field).setAttribute('aria-invalid', isInvalid) - if (isInvalid) { - $(field).focus() - } - return isInvalid - }) - .includes(true) -} - -export function setReleaseDateTime () { - const loadedDate = new Date() - releaseDateInput.value = getFormattedDate(loadedDate) -} - -export function getProfile () { - const fields = {} - for (const field of formInputs) { - if (field.id === 'field-datesortie') { - const dateSortie = field.value.split('-') - fields[ - field.id.substring('field-'.length) - ] = `${dateSortie[2]}/${dateSortie[1]}/${dateSortie[0]}` - } else { - fields[field.id.substring('field-'.length)] = field.value - } - } - return fields -} - -export function getReason () { - const checkedReasonInput = reasonInputs.find(input => input.checked) - const val = checkedReasonInput?.value - return val -} - -export function prepareInputs () { - formInputs.forEach((input) => { - const exempleElt = input.parentNode.parentNode.querySelector('.exemple') - const validitySpan = input.parentNode.parentNode.querySelector('.validity') - if (input.placeholder && exempleElt) { - input.addEventListener('input', (event) => { - if (input.value) { - exempleElt.innerHTML = 'ex. : ' + input.placeholder - validitySpan.removeAttribute('hidden') - } else { - exempleElt.innerHTML = '' - } - }) - } - }) - - $('#field-birthday').addEventListener('keyup', function (event) { - event.preventDefault() - const input = event.target - const key = event.keyCode || event.charCode - if (key !== 8 && key !== 46) { - input.value = addSlash(input.value) - } - }) - - reasonInputs.forEach(radioInput => { - radioInput.addEventListener('change', function (event) { - const isInError = reasonInputs.every(input => !input.checked) - reasonFieldset.classList.toggle('fieldset-error', isInError) - reasonAlert.classList.toggle('hidden', !isInError) - }) - }) - - $('#generate-btn').addEventListener('click', async (event) => { - event.preventDefault() - - const reason = getReason() - if (!reason) { - reasonFieldset.classList.add('fieldset-error') - reasonAlert.classList.remove('hidden') - reasonFieldset.scrollIntoView && reasonFieldset.scrollIntoView() - return - } - - const invalid = validateAriaFields() - if (invalid) { - return - } - - const pdfBlob = await generatePdf(getProfile(), reason, pdfBase) - - const creationInstant = new Date() - const creationDate = creationInstant.toLocaleDateString('fr-CA') - const creationHour = creationInstant - .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) - .replace(':', '-') - - downloadBlob(pdfBlob, `attestation-${creationDate}_${creationHour}.pdf`) - - snackbar.classList.remove('d-none') - setTimeout(() => snackbar.classList.add('show'), 100) - - setTimeout(function () { - snackbar.classList.remove('show') - setTimeout(() => snackbar.classList.add('d-none'), 500) - }, 6000) - }) -} +import { $, $$, downloadBlob } from './dom-utils' +import { addSlash, getFormattedDate } from './util' +import pdfBase from '../certificate.pdf' +import { generatePdf } from './pdf-util' +import SecureLS from 'secure-ls' + +const secureLS = new SecureLS({ encodingType: 'aes' }) +const formProfile = $('#form-profile') +const formInputs = $$('#form-profile input') +const snackbar = $('#snackbar') +const clearDataSnackbar = $('#snackbar-cleardata') +const reasonInputs = [...$$('input[name="field-reason"]')] +const reasonFieldset = $('#reason-fieldset') +const reasonAlert = reasonFieldset.querySelector('.msg-alert') +const releaseDateInput = $('#field-datesortie') +const releaseTimeInput = $('#field-heuresortie') +const storeDataInput = $('#field-storedata') + +const conditions = { + '#field-firstname': { + length: 1, + }, + '#field-lastname': { + length: 1, + }, + '#field-birthday': { + pattern: /^([0][1-9]|[1-2][0-9]|30|31)\/([0][1-9]|10|11|12)\/(19[0-9][0-9]|20[0-1][0-9]|2020)/g, + }, + '#field-lieunaissance': { + length: 1, + }, + '#field-address': { + length: 1, + }, + '#field-town': { + length: 1, + }, + '#field-zipcode': { + pattern: /\d{5}/g, + }, + '#field-datesortie': { + pattern: /\d{4}-\d{2}-\d{2}/g, + }, + '#field-heuresortie': { + pattern: /\d{2}:\d{2}/g, + }, +} + +function validateAriaFields () { + return Object.keys(conditions) + .map((field) => { + const fieldData = conditions[field] + const pattern = fieldData.pattern + const length = fieldData.length + const isInvalidPattern = pattern && !$(field).value.match(pattern) + const isInvalidLength = length && !$(field).value.length + + const isInvalid = !!(isInvalidPattern || isInvalidLength) + + $(field).setAttribute('aria-invalid', isInvalid) + if (isInvalid) { + $(field).focus() + } + return isInvalid + }) + .includes(true) +} + +function updateSecureLS () { + if (wantDataToBeStored() === true) { + secureLS.set('profile', getProfile()) + secureLS.set('reason', getReason()) + } else { + clearSecureLS() + } +} + +function clearSecureLS () { + secureLS.clear() +} + +function clearForm () { + formProfile.reset() +} + +function setCurrentDate () { + const currentDate = new Date() + + releaseDateInput.value = getFormattedDate(currentDate) + releaseTimeInput.value = currentDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) +} + +function showSnackbar (snackbarToShow, showDuration = 6000) { + snackbarToShow.classList.remove('d-none') + setTimeout(() => snackbarToShow.classList.add('show'), 100) + + setTimeout(function () { + snackbarToShow.classList.remove('show') + setTimeout(() => snackbarToShow.classList.add('d-none'), 500) + }, showDuration) +} + +export function setReleaseDateTime () { + const loadedDate = new Date() + releaseDateInput.value = getFormattedDate(loadedDate) +} + +export function getProfile () { + const fields = {} + for (const field of formInputs) { + if (field.id === 'field-datesortie') { + const dateSortie = field.value.split('-') + fields[ + field.id.substring('field-'.length) + ] = `${dateSortie[2]}/${dateSortie[1]}/${dateSortie[0]}` + } else { + fields[field.id.substring('field-'.length)] = field.value + } + } + return fields +} + +export function getReason () { + const checkedReasonInput = reasonInputs.find(input => input.checked) + return checkedReasonInput?.value +} + +export function wantDataToBeStored () { + return storeDataInput.checked +} + +export function prepareInputs () { + const lsProfile = secureLS.get('profile') + const lsReason = secureLS.get('reason') + const currentDate = new Date() + const formattedDate = getFormattedDate(currentDate) + const formattedTime = currentDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + + formInputs.forEach((input) => { + switch (input.name) { + case 'datesortie' : + input.value = formattedDate + break + case 'heuresortie' : + input.value = formattedTime + break + case 'field-reason' : + if (lsReason && input.value === lsReason) input.checked = true + break + case 'storedata' : + if (lsReason || lsProfile) input.checked = true + break + default : + if (lsProfile) input.value = lsProfile[input.name] + } + const exempleElt = input.parentNode.parentNode.querySelector('.exemple') + const validitySpan = input.parentNode.parentNode.querySelector('.validity') + if (input.placeholder && exempleElt) { + input.addEventListener('input', (event) => { + if (input.value) { + exempleElt.innerHTML = 'ex. : ' + input.placeholder + validitySpan.removeAttribute('hidden') + } else { + exempleElt.innerHTML = '' + } + }) + } + }) + + $('#field-birthday').addEventListener('keyup', function (event) { + event.preventDefault() + const input = event.target + const key = event.keyCode || event.charCode + if (key !== 8 && key !== 46) { + input.value = addSlash(input.value) + } + }) + + reasonInputs.forEach(radioInput => { + radioInput.addEventListener('change', function (event) { + const isInError = reasonInputs.every(input => !input.checked) + reasonFieldset.classList.toggle('fieldset-error', isInError) + reasonAlert.classList.toggle('hidden', !isInError) + }) + }) + + $('#formgroup-storedata').addEventListener('click', (event) => { + (storeDataInput.checked) ? storeDataInput.checked = false : storeDataInput.checked = true + }) + + $('#cleardata').addEventListener('click', (event) => { + clearSecureLS() + clearForm() + setCurrentDate() + showSnackbar(clearDataSnackbar, 1200) + }) + + $('#generate-btn').addEventListener('click', async (event) => { + event.preventDefault() + + const reason = getReason() + if (!reason) { + reasonFieldset.classList.add('fieldset-error') + reasonAlert.classList.remove('hidden') + reasonFieldset.scrollIntoView && reasonFieldset.scrollIntoView() + return + } + + const invalid = validateAriaFields() + if (invalid) { + return + } + + updateSecureLS() + + const pdfBlob = await generatePdf(getProfile(), reason, pdfBase) + + const creationInstant = new Date() + const creationDate = creationInstant.toLocaleDateString('fr-CA') + const creationHour = creationInstant + .toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + .replace(':', '-') + + downloadBlob(pdfBlob, `attestation-${creationDate}_${creationHour}.pdf`) + + showSnackbar(snackbar) + }) +}