Browse Source

init

master
Simon Vieille 4 weeks ago
parent
commit
cc837cf9e5
  1. 11
      .env
  2. 7
      .gitignore
  3. 38
      Makefile
  4. 354
      assets/css/admin.scss
  5. BIN
      assets/img/logo.png
  6. 31
      assets/js/addons/checkbox-checker.js
  7. 8
      assets/js/addons/choices.js
  8. 26
      assets/js/addons/datepicker.js
  9. 7
      assets/js/addons/dbclick.js
  10. 43
      assets/js/addons/document-selector.js
  11. 23
      assets/js/addons/editor.js
  12. 84
      assets/js/addons/form-collection.js
  13. 15
      assets/js/addons/form-confirm.js
  14. 11
      assets/js/addons/form.js
  15. 13
      assets/js/addons/modal.js
  16. 47
      assets/js/addons/panel.js
  17. 82
      assets/js/addons/password.js
  18. 44
      assets/js/addons/push-state.js
  19. 27
      assets/js/addons/rest-choices.js
  20. 24
      assets/js/addons/table-fixed.js
  21. 122
      assets/js/addons/table-selectable.js
  22. 11
      assets/js/addons/toast.js
  23. 5
      assets/js/addons/tooltip.js
  24. 28
      assets/js/admin.js
  25. 7
      bin/doctrine-migrate
  26. 11
      composer.json
  27. 4
      config/bundles.php
  28. 3
      config/packages/assets.yaml
  29. 4
      config/packages/dev/swiftmailer.yaml
  30. 4
      config/packages/prod/webpack_encore.yaml
  31. 15
      config/packages/scheb_2fa.yaml
  32. 49
      config/packages/security.yaml
  33. 3
      config/packages/swiftmailer.yaml
  34. 2
      config/packages/test/swiftmailer.yaml
  35. 2
      config/packages/test/webpack_encore.yaml
  36. 30
      config/packages/webpack_encore.yaml
  37. 13
      config/routes.yaml
  38. 7
      config/routes/scheb_2fa.yaml
  39. 31
      package.json
  40. 66
      public/.htaccess
  41. 1
      public/vendor/qrcodejs
  42. 98
      src/Authenticator/LoginFormAuthenticator.php
  43. 157
      src/Controller/Account/AccountAdminController.php
  44. 21
      src/Controller/Admin/AdminController.php
  45. 163
      src/Controller/Auth/AuthController.php
  46. 27
      src/Controller/Dashboard/DashboardAdminController.php
  47. 7
      src/Entity/Entity.php
  48. 244
      src/Entity/User.php
  49. 28
      src/Event/Account/PasswordRequestEvent.php
  50. 30
      src/Event/EntityManager/EntityManagerEvent.php
  51. 51
      src/EventSuscriber/AccountPasswordRequestEventSubscriber.php
  52. 39
      src/EventSuscriber/EntityManagerEventSubscriber.php
  53. 71
      src/Manager/EntityManager.php
  54. 338
      src/Notification/MailNotifier.php
  55. 67
      src/Repository/UserRepository.php
  56. 13
      src/Security/TokenGenerator.php
  57. 109
      symfony.lock
  58. 226
      templates/account/admin/edit.html.twig
  59. 113
      templates/admin/layout.html.twig
  60. 16
      templates/admin/module/account.html.twig
  61. 51
      templates/admin/module/flashes.html.twig
  62. 14
      templates/admin/module/metas.html.twig
  63. 83
      templates/auth/2fa.html.twig
  64. 95
      templates/auth/login.html.twig
  65. 80
      templates/auth/resetting_request.html.twig
  66. 113
      templates/auth/resetting_update.html.twig
  67. 1
      templates/dashboard/admin/index.html.twig
  68. 48
      templates/mail/base.html.twig
  69. 1
      templates/mail/raw.html.twig
  70. 13
      templates/mail/resetting_request.html.twig
  71. 72
      webpack.config.js
  72. 6320
      yarn.lock

11
.env

@ -18,10 +18,6 @@ APP_ENV=dev
APP_SECRET=e6e287f176fe2c69112fc620e1801bf0
###< symfony/framework-bundle ###
###> symfony/mailer ###
# MAILER_DSN=smtp://localhost
###< symfony/mailer ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
@ -30,3 +26,10 @@ APP_SECRET=e6e287f176fe2c69112fc620e1801bf0
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/swiftmailer-bundle ###
# For Gmail as a transport, use: "gmail://username:password@localhost"
# For a generic SMTP server, use: "smtp://localhost:25?encryption=&auth_mode="
# Delivery is disabled by default via "null://localhost"
MAILER_URL=null://localhost
###< symfony/swiftmailer-bundle ###

7
.gitignore

@ -14,3 +14,10 @@
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/webpack-encore-bundle ###
/node_modules/
/public/build/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###

38
Makefile

@ -0,0 +1,38 @@
COMPOSER ?= composer
PHP ?= php7.4
MAGE ?= mage
SSH ?= ssh
WEBPACK ?= webpack
YARN ?= yarn
SCHEMASPY ?= schemaspy
all: dep asset clean
.ONESHELL:
dep:
$(COMPOSER) update --ignore-platform-reqs
$(COMPOSER) install --ignore-platform-reqs
$(YARN)
asset-watch:
$(WEBPACK) -w
asset:
$(YARN)
$(WEBPACK)
clean:
rm -fr var/cache/dev/*
rm -fr var/cache/prod/*
browse-dev:
x-www-browser https://local.tinternet
deploy-prod:
$(MAGE) deploy prod
deploy-preprod:
$(MAGE) deploy preprod
doctrine-migration:
PHP=$(PHP) ./bin/doctrine-migrate

354
assets/css/admin.scss

@ -0,0 +1,354 @@
$theme-colors: (
"primary": #1ab5dc,
"primary-light": lighten(#3183aa, 40%),
"dark-blue": #1e2430,
);
$grid-gutter-width: 0px;
$pagination-color: #343a40;
$pagination-bg: #ffffff;
$pagination-active-color: #ffffff;
$pagination-active-bg: #343a40;
@import "~choices.js/src/styles/choices.scss";
@import "~bootstrap/scss/bootstrap.scss";
@import "~@fortawesome/fontawesome-free/css/all.css";
#logo {
width: 30px;
}
.choices__list--dropdown {
display: none;
}
.choices__list--dropdown.is-active {
display: block;
}
.dropdown-toggle-hide-after {
&::after {
display: none;
}
}
.login {
&-container {
margin-top: 5%;
margin-bottom: 5%;
}
&-form {
padding: 5%;
}
&-image {
width: 100%;
max-width: 80%;
}
}
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100; /* Behind the navbar */
padding: 71px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 71px);
padding-top: .5rem;
overflow-x: hidden;
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
}
@supports ((position: -webkit-sticky) or (position: sticky)) {
.sidebar-sticky {
position: -webkit-sticky;
position: sticky;
}
}
.actions-container {
padding-right: 25px;
}
.thead-light {
a, th {
color: #333333;
}
}
tr.table-primary-light {
background-color: #ecf5fa;
}
.td-nowrap {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bg-dark-blue {
background: #242b3b;
color: #fff;
.nav-item-label {
color: #fff;
}
}
.sidebar {
.nav-link {
font-weight: 500;
color: #333;
border-left: 4px solid map-get($theme-colors, 'dark-blue');
padding-top: 14px;
padding-bottom: 14px;
.fa {
font-size: 1.2rem;
margin-right: 5px;
min-width: 20px;
color: #fff;
}
&.active {
font-weight: bold;
border-left: 4px solid map-get($theme-colors, 'primary');
background: map-get($theme-colors, 'dark-blue');
}
}
&-heading {
font-size: .75rem;
text-transform: uppercase;
display: flex;
}
@media screen and (max-width: 1130px) {
.nav-link {
font-size: 14px;
}
}
@media screen and (max-width: 770px) {
.nav {
padding-left: 0;
}
.nav-link {
padding-left: 10px;
}
.nav-item-label {
display: none;
}
.sidebar-heading {
display: none;
}
width: 50px;
}
}
*[data-selectable-selector] {
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
}
*[data-selectable-selector] {
&:hover {
cursor: pointer;
}
}
.footer {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
z-index: 1000;
height: 35px;
background: #f8f9fa;
}
.body {
padding-top: 71px;
.nav {
padding-left: 10px;
}
}
@media screen and (max-width: 580px) {
.body {
margin-left: 45px;
}
.sidebar {
width: 50px;
max-width: 100% !important;
.sidebar-sticky {
width: 50px;
max-width: 100% !important;
}
}
}
table.table-fixed, .table-fixed > table {
width: 100%;
tbody {
overflow: auto;
width: 100%;
height: 500px;
}
thead, tbody, tr, td, th{
display: block;
}
tbody {
td {
float: left;
min-height: 60px;
}
tr {
clear: left;
}
}
thead {
tr {
th {
float: left;
&.sorted {
&::before {
content: '\f0dc';
font-family: 'FontAwesome';
color: #aaa;
margin-right: 3px;
}
}
}
}
}
}
.toast-container {
display: flex;
position: relative;
z-index: 1000;
.toast-wrapper {
position: fixed;
top: 80px;
right: calc(50% - 150px);
z-index: 1000;
width: 300px;
}
}
.tab-form {
padding: 15px;
}
.icon-margin {
margin-right: 4px;
}
.file-icon {
font-size: 2em;
}
.d-ib {
display: inline-block;
}
.list-checkbox {
vertical-align: middle;
margin-right: 10px;
margin-top: -2px;
}
.password-strenth {
padding: 0 0 5px 0;
margin-top: -4px;
.col-sm {
height: 8px;
border: 2px solid #fff;
}
&-info {
font-size: 13px;
height: 22px;
}
}
.notification-bell:not([disabled]) {
[data-counter]:after {
display: block;
color: #fff;
background: red;
width: 9px;
height: 9px;
position: absolute;
content: ' ';
top: 4px;
right: 10px;
border-radius: 4px;
}
}
.form-error-icon {
margin-right: 4px;
}
.custom-file-label::after {
content: "Parcourir";
}
#lease_template_html {
height: calc(100vh - 270px);
}
.panel {
&-toggler {
&:hover {
cursor: pointer;
}
}
&-content {
display: block;
&:not(.active) {
display: none;
}
}
}
*[data-collection-delete-container] {
cursor: pointer;
margin-top: 35px;
}
*[data-collection-add] {
cursor: pointer;
}
.login-image {
width: 50%;
}

BIN
assets/img/logo.png

After

Width: 465  |  Height: 479  |  Size: 32 KiB

31
assets/js/addons/checkbox-checker.js

@ -0,0 +1,31 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-checkbox-ckecker]').click(function() {
const wrapperName = $(this).attr('data-checkbox-ckecker');
if (!wrapperName) {
return;
}
const checkboxes = $('*[data-checkbox-wrapper="' + wrapperName + '"] *[data-checkbox] input[type="checkbox"]');
$(checkboxes).each(function(i, v) {
$(v).prop('checked', true);
})
})
$('*[data-checkbox-unckecker]').click(function() {
const wrapperName = $(this).attr('data-checkbox-unckecker');
if (!wrapperName) {
return;
}
const checkboxes = $('*[data-checkbox-wrapper="' + wrapperName + '"] *[data-checkbox] input[type="checkbox"]');
$(checkboxes).each(function(i, v) {
$(v).prop('checked', false);
})
})
};

8
assets/js/addons/choices.js

@ -0,0 +1,8 @@
const Choices = require('choices.js');
const $ = require('jquery');
module.exports = function() {
$('*[data-jschoice]').each(function(key, item) {
new Choices(item);
});
}

26
assets/js/addons/datepicker.js

@ -0,0 +1,26 @@
const Datepicker = require('vanillajs-datepicker')
const isDateSupported = () => {
const input = document.createElement('input');
const value = 'a';
input.setAttribute('type', 'date');
input.setAttribute('value', value);
return input.value !== value;
}
module.exports = () => {
if (isDateSupported()) {
return
}
const inputs = document.querySelectorAll('input[type="date"]')
const size = inputs.length
for (var i = 0, c = inputs.length; i < c; i++) {
new Datepicker.Datepicker(inputs[i], {
format: 'yyyy-mm-dd'
})
}
}

7
assets/js/addons/dbclick.js

@ -0,0 +1,7 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-dblclick]').dblclick(function(e) {
document.location.href = $(this).attr('data-dblclick');
})
};

43
assets/js/addons/document-selector.js

@ -0,0 +1,43 @@
const $ = require('jquery');
let DocumentSelector = () => {
let forms = $('.document-selector-form');
let btnSubmit = $('#download-archive-form button');
let handler = function() {
forms.each((fi, f) => {
let form = $(f);
let ids = form.find('.document-selector-ids');
let btn = form.find('.document-selector-button');
ids.html('');
let hasSelection = false;
$('*[data-documents] *[data-selectable-row] input[data-selectable-checkbox]').each((i, c) => {
let checkbox = $(c);
if (checkbox.is(':checked')) {
ids.append(checkbox[0].outerHTML);
hasSelection = true;
}
});
if (hasSelection && btn.length) {
btn.removeAttr('disabled');
ids.find('input').prop('checked', true);
} else {
btn.attr('disabled', 'disabled');
}
})
}
$('*[data-documents] *[data-selectable-row]').click(function() {
window.setTimeout(handler, 100)
});
$('*[data-documents] *[data-selectable-row]').on('clicked', function() {
window.setTimeout(handler, 100)
});
}
module.exports = DocumentSelector;

23
assets/js/addons/editor.js

@ -0,0 +1,23 @@
module.exports = function() {
if (typeof tinymce === 'undefined') {
return;
}
tinymce.init({
selector: '*[data-tinymce]',
base_url: '/nm/tinymce/',
cache_suffix: '?v=4.1.6',
language: 'fr_FR',
plugins: 'print preview importcss searchreplace visualblocks visualchars fullscreen template table charmap hr pagebreak nonbreaking toc insertdatetime advlist lists wordcount textpattern noneditable help charmap quickbars',
menubar: 'file edit view insert format tools table tc help',
toolbar: 'undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist checklist | forecolor backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap | fullscreen preview | code',
importcss_append: true,
image_caption: true,
quickbars_selection_toolbar: 'bold italic | quicklink h2 h3 blockquote quickimage quicktable',
noneditable_noneditable_class: "mceNonEditable",
toolbar_drawer: 'sliding',
spellchecker_dialog: true,
tinycomments_mode: 'embedded',
contextmenu: "link image imagetools table configurepermanentpen",
});
};

84
assets/js/addons/form-collection.js

@ -0,0 +1,84 @@
const $ = require('jquery');
const DeleteHandler = (e) => {
e.stopPropagation()
const target = e.target;
let button = $(target);
if (button.is('[data-collection-delete-container]')) {
button = button.find('*[data-collection-delete]').first()
}
const id = button.attr('data-collection-delete');
const collection = button.parents('[data-collection]')
const item = collection.find('*[data-collection-item="' + id + '"]')
if (confirm('Validez-vous la suppression ?')) {
item.remove();
collection.trigger('collection.update');
}
}
const CollectionInitilizedAndUpdated = (e) => {
const target = $(e.target)
target.find('*[data-collection-empty]').toggleClass(
'd-none',
target.find('*[data-collection-item]').length !== 0
);
target.find('*[data-collection-nonempty]').toggleClass(
'd-none',
target.find('*[data-collection-item]').length === 0
);
}
const FormCollection = () => {
$('*[data-collection]').on(
'collection.update',
CollectionInitilizedAndUpdated
);
$('*[data-collection]').on(
'collection.init',
CollectionInitilizedAndUpdated
);
$('*[data-collection]').on(
'click',
'*[data-collection-delete], *[data-collection-delete-container]',
DeleteHandler
);
$('*[data-collection-add]').click((e) => {
e.stopPropagation()
const collectionId = $(e.target).attr('data-collection-add')
const collectionContainer = $('*[data-collection="' + collectionId + '"]')
const prototypeContent = $('#' + collectionId).html()
let name = 0
collectionContainer.find('*[data-collection-item]').each(function() {
var n = parseInt($(this).attr('data-collection-item'))
if (n >= name) {
name = n + 1
}
})
collectionContainer.append(prototypeContent)
const item = collectionContainer.children('*[data-collection-item]:last-child')
const deleteBtn = $('<span data-collection-delete="__name__" class="fa fa-trash"></span>')
item.find('*[data-collection-delete-container]').first().append(deleteBtn)
item.html(item.html().replace(/__name__/g, name))
item.attr('data-collection-item', name)
collectionContainer.trigger('collection.update');
});
$('*[data-collection]').trigger('collection.init');
}
module.exports = FormCollection;

15
assets/js/addons/form-confirm.js

@ -0,0 +1,15 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-form-confirm]').submit(function(e) {
let message = $(this).attr('data-form-confirm');
if (!message) {
message = 'Confimez-vous cette action ?';
}
if (!confirm(message)) {
e.preventDefault();
}
})
};

11
assets/js/addons/form.js

@ -0,0 +1,11 @@
const $ = require('jquery');
module.exports = function() {
$('.custom-file-input').on('change', function(event) {
let inputFile = event.currentTarget;
$(inputFile).parent()
.find('.custom-file-label')
.html(inputFile.files[0].name);
});
};

13
assets/js/addons/modal.js

@ -0,0 +1,13 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-modal]').click((e) => {
e.preventDefault();
e.stopPropagation();
let id = $(e.target).attr('data-modal');
let modal = $(id);
modal.modal('toggle');
});
}

47
assets/js/addons/panel.js

@ -0,0 +1,47 @@
const $ = require('jquery');
let Pannel = () => {
let panels = $('.panel');
panels.each((i, p) => {
let panel = $(p);
let content = panel.find('.panel-content').first();
let togglers = panel.find('.panel-toggler');
togglers.each((k, t) => {
let toggler = $(t);
if (!toggler.is('.fa')) {
return;
}
if (content.is('.active')) {
toggler.removeClass('fa-arrow-down');
toggler.addClass('fa-arrow-up');
} else {
toggler.removeClass('fa-arrow-up');
toggler.addClass('fa-arrow-down');
}
})
togglers.click(function(e) {
e.stopPropagation();
content.toggleClass('active');
togglers.each((k, t) => {
let toggler = $(t);
if (!toggler.is('.fa')) {
return;
}
toggler
.toggleClass('fa-arrow-down')
.toggleClass('fa-arrow-up');
})
});
});
}
module.exports = Pannel;

82
assets/js/addons/password.js

@ -0,0 +1,82 @@
const $ = require('jquery');
const zxcvbn = require('zxcvbn');
let scoreColors = [
'danger',
'danger',
'warning',
'warning',
'success',
];
let scoreInfos = {
"This is a top-10 common password": "Parmis le top 10 des mots de passes communs",
"This is a top-100 common password": "Parmis le top 100 des mots de passes communs",
"This is a very common password": "Mot de passe vraiment trop commun",
"This is similar to a commonly used password": "Similaire à un mot de passe commun",
"A word by itself is easy to guess": "Ce mot est trop simple à deviner",
"Names and surnames by themselves are easy to guess": "Les noms ou les surnoms sont simples à deviner",
"Common names and surnames are easy to guess": "Les noms ou les surnoms sont simples à deviner",
"Straight rows of keys are easy to guess": "Combinaison de touches trop simple",
"Short keyboard patterns are easy to guess": "Combinaison de touches trop simple",
"Repeats like \"aaa\" are easy to guess'": "Les répétitions comme \"aaa\" sont simples à deviner",
"Repeats like \"abcabcabc\" are only slightly harder to guess than \"abc\"": "Les répétitions comme \"abcabcabc\" sont simples à deviner",
"Sequences like abc or 6543 are easy to guess": "Les séquences comme \"abc\" ou \"6543\" sont simples à deviner",
"Recent years are easy to guess": "Les années sont simples à deviner",
"Dates are often easy to guess": "Les dates sont souvent simples à deviner",
}
let checkPassword = function(password, confirmation, indicator, submit) {
let result = zxcvbn(password.val());
let score = result.score;
let cols = indicator.children('.col-sm');
let info = indicator.children('.password-strenth-info');
info.text('');
cols.attr('class', 'col-sm');
for (var u = 0; u <= 5; u++) {
let col = cols.eq(u);
if (u <= score) {
col.addClass('bg-' + scoreColors[score]);
} else {
col.addClass('bg-light');
}
}
console.log(result)
info.text(scoreInfos[result.feedback.warning]);
info.attr('class', 'col-12 password-strenth-info text-' + scoreColors[score]);
if (score < 4 || confirmation.val() !== password.val()) {
submit.attr('disabled', 'disabled');
} else {
submit.removeAttr('disabled');
}
}
module.exports = function() {
let passwordNew = $('#form-password-new');
let passwordConfirmation = $('#form-password-confirmation');
let passwordSubmit = $('#form-password-submit');
let passwordStrength = $('#form-password-strength');
if (passwordStrength.length) {
passwordNew.keyup(function() {
checkPassword(passwordNew, passwordConfirmation, passwordStrength, passwordSubmit);
});
passwordNew.change(function() {
checkPassword(passwordNew, passwordConfirmation, passwordStrength, passwordSubmit);
});
passwordConfirmation.keyup(function() {
checkPassword(passwordNew, passwordConfirmation, passwordStrength, passwordSubmit);
});
passwordConfirmation.change(function() {
checkPassword(passwordNew, passwordConfirmation, passwordStrength, passwordSubmit);
});
}
};

44
assets/js/addons/push-state.js

@ -0,0 +1,44 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-pushstate]').click((e) => {
var url = $(e.target).attr('data-pushstate');
history.pushState({url: url}, null, url);
history.replaceState({url: url}, null, url);
});
let forms = $('form[data-formpushstate]');
let checkAndUsePushState = () => {
let state = [window.location.pathname, window.location.search].join('');
$('*[data-pushstate]').each((i, v) => {
let method = 'compare';
if ($(v).is('[data-pushstate-method]')) {
method = $(v).attr('data-pushstate-method')
}
var isThisOne = false;
if (method === 'compare' && $(v).attr('data-pushstate') === state) {
isThisOne = true;
}
if (method === 'indexOf' && state.indexOf($(v).attr('data-pushstate')) !== -1) {
isThisOne = true;
}
if (isThisOne) {
$(v).click();
forms.attr('action', state);
}
});
}
checkAndUsePushState();
$(window).on('statechange', checkAndUsePushState, false);
}

27
assets/js/addons/rest-choices.js

@ -0,0 +1,27 @@
const $ = require('jquery');
const Choices = require('choices.js');
module.exports = function() {
$('*[data-rest-choices]').each(function(key, item) {
const url = $(this).attr('data-rest-choices');
new Choices(item, {
searchPlaceholderValue: 'Chercher',
}).setChoices(function() {
return fetch(url)
.then(function(response) {
return response.json();
})
.then(function(data) {
return data.map(function(d) {
return {
label: d.label,
value: d.value
};
});
});
})
.then(function(instance) {
});
})
};

24
assets/js/addons/table-fixed.js

@ -0,0 +1,24 @@
const $ = require('jquery');
let resizeTbody = (tbody) => {
tbody.height($(window).height() - tbody.offset().top - 20);
}
let tableFixed = () => {
let tables = $('table[data-table-fixed], *[data-table-fixed] > table');
tables.each((i, t) => {
let table = $(t);
table.addClass('table-fixed');
let tbody = table.find('tbody');
resizeTbody(tbody);
$(window).resize(function() {
resizeTbody(tbody);
});
});
}
module.exports = tableFixed;

122
assets/js/addons/table-selectable.js

@ -0,0 +1,122 @@
const $ = require('jquery');
const selectedClass = 'table-primary-light';
let toggleRow = (row, checkbox, checkboxIsClicked) => {
row.toggleClass(selectedClass);
if (checkboxIsClicked) {
checkbox.prop('checked', checkbox.prop('checked'));
return;
}
if (checkbox.length) {
checkbox.prop('checked', !checkbox.prop('checked'));
}
}
let unactiveRow = (row, checkbox) => {
row.removeClass(selectedClass);
if (checkbox.length) {
checkbox.prop('checked', false);
}
}
let activeRow = (row, checkbox) => {
row.addClass(selectedClass);
if (checkbox.length) {
checkbox.prop('checked', true);
}
}
let tableSelectable = () => {
let tables = $('*[data-selectable]');
tables.each((i, t) => {
var table = $(t);
var rows = table.find('*[data-selectable-row]');
let selectedIndex = null;
var tbody = table.find('tbody');
var resizer = () => {
tbody.height($(window).height() - tbody.offset().top - 20);
}
window.setInterval(resizer, 1000);
resizer();
$(window).resize(resizer);
((rows) => {
rows.each((i, r) => {
let row = $(r);
let checkbox = row.find('*[data-selectable-checkbox]');
let selectors = row.find('*[data-selectable-selector]');
((row, selectors, checkbox, index) => {
selectors.click((e) => {
if (event.target.nodeName === 'INPUT') {
e.stopPropagation();
checkbox.trigger('clicked');
return toggleRow(row, checkbox, true);
}
if (window.event.ctrlKey) {
e.preventDefault();
return toggleRow(row, checkbox);
}
if (window.event.button === 0) {
if (!window.event.ctrlKey && !window.event.shiftKey) {
rows.each((z, r2) => {
unactiveRow($(r2), $(r2).find('*[data-selectable-checkbox]'));
});
toggleRow(row, checkbox);
if (row.hasClass(selectedClass)) {
selectedIndex = index;
} else {
selectedIndex = null;
}
return;
}
if (window.event.shiftKey) {
if (selectedIndex !== null) {
rows.each((z, r2) => {
if (selectedIndex <= index) {
if (z >= selectedIndex && z <= index) {
activeRow($(r2), $(r2).find('*[data-selectable-checkbox]'));
} else {
unactiveRow($(r2), $(r2).find('*[data-selectable-checkbox]'));
}
} else {
if (z <= selectedIndex && z >= index) {
activeRow($(r2), $(r2).find('*[data-selectable-checkbox]'));
} else {
unactiveRow($(r2), $(r2).find('*[data-selectable-checkbox]'));
}
}
});
//selectedIndex = index;
}
}
}
});
})(row, selectors, checkbox, i);
});
})(rows);
});
}
module.exports = tableSelectable;

11
assets/js/addons/toast.js

@ -0,0 +1,11 @@
const $ = require('jquery');
module.exports = function() {
$('.toast').toast({
animation: true,
autohide: true,
delay: 5000,
});
$('.toast').toast('show');
};

5
assets/js/addons/tooltip.js

@ -0,0 +1,5 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-toggle="tooltip"]').tooltip();
};

28
assets/js/admin.js

@ -0,0 +1,28 @@
const imagesContext = require.context(
'../img',
true, /\.(png|jpg|jpeg|gif|ico|svg|webp)$/
);
imagesContext.keys().forEach(imagesContext);
import '../css/admin.scss';
require('../../node_modules/bootstrap/dist/js/bootstrap.min.js');
// require('./addons/table-selectable.js')();
// require('./addons/table-fixed.js')();
// require('./addons/document-selector.js')();
require('./addons/form-confirm.js')();
require('./addons/form.js')();
require('./addons/dbclick.js')();
require('./addons/toast.js')();
require('./addons/modal.js')();
require('./addons/push-state.js')();
require('./addons/password.js')();
require('./addons/tooltip.js')();
require('./addons/editor.js')();
require('./addons/panel.js')();
require('./addons/choices.js')();
require('./addons/checkbox-checker.js')();
require('./addons/rest-choices.js')();
require('./addons/form-collection.js')();
require('./addons/datepicker.js')();

7
bin/doctrine-migrate

@ -0,0 +1,7 @@
#!/bin/sh
CLASS_NAME="$(echo "yes" | "$PHP" ./bin/console doctrine:migration:diff -e dev | grep -o "Version[0-9]*" | tail -n 1)"
if [ -n "$CLASS_NAME" ]; then
echo "yes" | "$PHP" ./bin/console doctrine:migration:exec --up "DoctrineMigrations\\$CLASS_NAME" -e dev
fi

11
composer.json

@ -7,16 +7,23 @@
"php": ">=7.2.5",
"ext-ctype": "*",
"ext-iconv": "*",
"bjeavons/zxcvbn-php": "^1.2",
"cocur/slugify": "^4.0",
"composer/package-versions-deprecated": "1.11.99.1",
"doctrine/annotations": "^1.0",
"doctrine/doctrine-bundle": "^2.2",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.8",
"knplabs/knp-paginator-bundle": "^5.4",
"phpdocumentor/reflection-docblock": "^5.2",
"sensio/framework-extra-bundle": "^5.1",
"scheb/2fa-google-authenticator": "^5.7",
"scheb/2fa-qr-code": "^5.7",
"sensio/framework-extra-bundle": "^6.1",
"symfony/apache-pack": "^1.0",
"symfony/asset": "5.2.*",
"symfony/console": "5.2.*",
"symfony/dotenv": "5.2.*",
"symfony/event-dispatcher": "5.2.*",
"symfony/expression-language": "5.2.*",
"symfony/flex": "^1.3.1",
"symfony/form": "5.2.*",
@ -34,10 +41,12 @@
"symfony/security-bundle": "5.2.*",
"symfony/serializer": "5.2.*",
"symfony/string": "5.2.*",
"symfony/swiftmailer-bundle": "^3.5",
"symfony/translation": "5.2.*",
"symfony/twig-bundle": "^5.2",
"symfony/validator": "5.2.*",
"symfony/web-link": "5.2.*",
"symfony/webpack-encore-bundle": "^1.11",
"symfony/yaml": "5.2.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"

4
config/bundles.php

@ -12,4 +12,8 @@ return [
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
];

3
config/packages/assets.yaml

@ -0,0 +1,3 @@
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'

4
config/packages/dev/swiftmailer.yaml

@ -0,0 +1,4 @@
# See https://symfony.com/doc/current/email/dev_environment.html
swiftmailer:
# send all emails to a specific address
#delivery_addresses: ['me@example.com']

4
config/packages/prod/webpack_encore.yaml

@ -0,0 +1,4 @@
#webpack_encore:
# Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# Available in version 1.2
#cache: true

15
config/packages/scheb_2fa.yaml

@ -0,0 +1,15 @@
# See the configuration reference at https://github.com/scheb/2fa/blob/master/doc/configuration.md
scheb_two_factor:
google:
enabled: true
issuer: "Tinternet & cie"
server_name:
digits: 6
window: 1
template: auth/2fa.html.twig
security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
# If you're using guard-based authentication, you have to use this one:
- Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken
# If you're using authenticator-based security (introduced in Symfony 5.1), you have to use this one:
# - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

49
config/packages/security.yaml

@ -1,24 +1,49 @@
security:
encoders:
App\Entity\User:
algorithm: auto
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
providers:
users_in_memory: { memory: null }
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
role_hierarchy:
ROLE_WRITER: ROLE_USER
ROLE_ADMIN: ROLE_WRITER
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
lazy: true
provider: users_in_memory
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#firewalls-authentication
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
anonymous: ~
two_factor:
auth_form_path: 2fa_login # The route name you have used in the routes.yaml
check_path: 2fa_login_check # The route name you have used in the routes.yaml
guard:
authenticators:
- App\Authenticator\LoginFormAuthenticator
form_login:
login_path: auth_login
check_path: auth_login
csrf_token_generator: security.csrf.token_manager
logout:
path: auth_logout
target: /
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: ^/admin, roles: ROLE_USER }
- { path: ^/_internal, roles: IS_AUTHENTICATED_ANONYMOUSLY }

3
config/packages/swiftmailer.yaml

@ -0,0 +1,3 @@
swiftmailer:
url: '%env(MAILER_URL)%'
spool: { type: 'memory' }

2
config/packages/test/swiftmailer.yaml

@ -0,0 +1,2 @@
swiftmailer:
disable_delivery: true

2
config/packages/test/webpack_encore.yaml

@ -0,0 +1,2 @@
#webpack_encore:
# strict_mode: false

30
config/packages/webpack_encore.yaml

@ -0,0 +1,30 @@
webpack_encore:
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
output_path: '%kernel.project_dir%/public/build'
# If multiple builds are defined (as shown below), you can disable the default build:
# output_path: false
# Set attributes that will be rendered on all script and link tags
script_attributes:
defer: true
# link_attributes:
# If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')
# crossorigin: 'anonymous'
# Preload all rendered script and link tags automatically via the HTTP/2 Link header
# preload: true
# Throw an exception if the entrypoints.json file is missing or an entry is missing from the data
# strict_mode: false
# If you have multiple builds:
# builds:
# pass "frontend" as the 3rg arg to the Twig functions
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}
# frontend: '%kernel.project_dir%/public/frontend/build'
# Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# Put in config/packages/prod/webpack_encore.yaml
# cache: true

13
config/routes.yaml

@ -1,3 +1,16 @@
#index:
# path: /
# controller: App\Controller\DefaultController::index
#https://symfony.com/doc/current/routing/custom_route_loader.html
#admin_routes:
# resource: 'admin_route_loader::loadRoutes'
# type: service
#
2fa_login:
path: /2fa
defaults:
_controller: "scheb_two_factor.form_controller:form"
2fa_login_check:
path: /2fa_check

7
config/routes/scheb_2fa.yaml

@ -0,0 +1,7 @@
2fa_login:
path: /2fa
defaults:
_controller: "scheb_two_factor.form_controller:form"
2fa_login_check:
path: /2fa_check

31
package.json

@ -0,0 +1,31 @@
{
"devDependencies": {
"@symfony/stimulus-bridge": "^2.0.0",
"@symfony/webpack-encore": "^1.0.0",
"core-js": "^3.0.0",
"node-sass": "^4.13.1",
"regenerator-runtime": "^0.13.2",
"sass-loader": "^7.0.1",
"stimulus": "^2.0.0",
"webpack-notifier": "^1.6.0"
},
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.11.2",
"bootstrap": "^4.3.1",
"choices.js": "^9.0.1",
"jquery": "^3.6.0",
"popper.js": "^1.16.0",
"qrcodejs": "^1.0.0",
"tinymce": "^5.2.0",
"vanillajs-datepicker": "^1.1.2",
"zxcvbn": "^4.4.2"
}
}

66
public/.htaccess

@ -0,0 +1,66 @@
# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeScript assets.
# Options +FollowSymlinks
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller ->
# redirect -> request -> ...).
# So in case you get a "too many redirects" error or you always get redirected
# to the start page because your Apache does not expose the REDIRECT_STATUS
# environment variable, you have 2 choices:
# - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
# Rewrite all other queries to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>

1
public/vendor/qrcodejs

@ -0,0 +1 @@
../../node_modules/qrcodejs

98
src/Authenticator/LoginFormAuthenticator.php

@ -0,0 +1,98 @@
<?php
namespace App\Authenticator;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
use TargetPathTrait;
private EntityManagerInterface $entityManager;
private UrlGeneratorInterface $urlGenerator;
private CsrfTokenManagerInterface $csrfTokenManager;
private UserPasswordEncoderInterface $passwordEncoder;
public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder)
{
$this->entityManager = $entityManager;
$this->urlGenerator = $urlGenerator;
$this->csrfTokenManager = $csrfTokenManager;
$this->passwordEncoder = $passwordEncoder;
}
public function supports(Request $request)
{
return 'auth_login' === $request->attributes->get('_route') && $request->isMethod('POST');
}
public function getCredentials(Request $request)
{
$credentials = [
'email' => $request->request->get('_username'),
'password' => $request->request->get('_password'),
'csrf_token' => $request->request->get('_csrf_token'),
];
$request->getSession()->set(Security::LAST_USERNAME, $credentials['email']);
return $credentials;
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$token = new CsrfToken('authenticate', $credentials['csrf_token']);
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw new InvalidCsrfTokenException();
}
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);
if (!$user) {
// fail authentication with a custom error
throw new CustomUserMessageAuthenticationException('Email could not be found.');
}
return $user;
}
public function checkCredentials($credentials, UserInterface $user)
{
return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('admin_dashboard_index'));
}
protected function getLoginUrl()
{
return $this->urlGenerator->generate('auth_login');
}
}

157
src/Controller/Account/AccountAdminController.php

@ -0,0 +1,157 @@
<?php
namespace App\Controller\Account;
use App\Repository\UserRepository;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface as TotpAuthenticatorInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use ZxcvbnPhp\Zxcvbn;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use App\Controller\Admin\AdminController;
use App\Manager\EntityManager;
/**
* @Route("/admin/account")
*/
class AccountAdminController extends AdminController
{
/**
* @Route("/", name="admin_account")
*/
public function account(Request $request, TotpAuthenticatorInterface $totpAuthenticatorService): Response