diff --git a/pass/app/PaymentReference.js b/pass/app/PaymentReference.js index 6e756de7b585be40db31f0a879e413733ac86134..787ce1a830b04a7c78e4cac0c547a3402af8f447 100644 --- a/pass/app/PaymentReference.js +++ b/pass/app/PaymentReference.js @@ -59,12 +59,14 @@ class PaymentReference { * @param {number} amount * @param {string} key * @param {dayjs.Dayjs} expiration + * @param {boolean} expiration * @returns */ static async CREATE_NEW_REQUEST( amount, key, - expiration = dayjs().add(PaymentReference.DEFAULT_EXPIRATION_HOURS, "hours") + expiration = dayjs().add(PaymentReference.DEFAULT_EXPIRATION_HOURS, "hours"), + ignore_charge_limit = false ) { // Calculate price from amount let price = amount * config.get("price.per_token"); @@ -78,7 +80,7 @@ class PaymentReference { return Key.GET_KEY(key) .then((key) => { - if (!key.isChargable()) { + if (!key.isChargable() && !ignore_charge_limit) { throw "Key cannot be charged"; } else { loaded_key = key; diff --git a/pass/lang/de/admin.json b/pass/lang/de/admin.json index 577514ae0b3a814efd5b1d130574a9c86d1f3d01..423bf472bcdd604b79c75208c4f529317420028a 100644 --- a/pass/lang/de/admin.json +++ b/pass/lang/de/admin.json @@ -2,14 +2,16 @@ "breadcrumps": { "overview": "Übersicht", "payments-cash": "Barzahlung erfassen", - "receipt": "Rechnung erstellen" + "receipt": "Rechnung erstellen", + "key-management": "Schlüsselverwaltung" }, "index": { "heading": "MetaGer Schlüssel Admin", "actions": { "cash-payment": "Bargeldzahlung erfassen", "heading": "Aktionen:", - "receipt": "Rechnung erstellen" + "receipt": "Rechnung erstellen", + "keymanagement": "Schlüssel verwalten" } }, "cash-payment": { @@ -49,5 +51,28 @@ "label": "Anschrift" }, "submit-userdata": "Rechnungsdaten übernehmen" + }, + "key": { + "key-input": { + "label": "Schlüssel eingeben", + "submit": "Abschicken" + }, + "key-overview": { + "charge": "Guthaben: {{token}}", + "charge-success": "Aufladung erfolgreich", + "delete": "Löschen", + "expiration": "Gültig bis {{expiration}}", + "charge-form": { + "heading": "Manuelle Aufladung", + "hint": "Für eine manuelle Aufladung wird keine Zahlung hinterlegt. Das Guthaben wird einfach ohne Gegenleistung gutgeschrieben. Vorsichtig verwenden!", + "amount": { + "label": "Anzahl" + }, + "price": { + "label": "Betrag (€)" + }, + "submit": "Jetzt aufladen!" + } + } } -} +} \ No newline at end of file diff --git a/pass/public/js/admin/key-overview.js b/pass/public/js/admin/key-overview.js new file mode 100644 index 0000000000000000000000000000000000000000..971e25916694412a20f5d308277252c1b79dd5dd --- /dev/null +++ b/pass/public/js/admin/key-overview.js @@ -0,0 +1,14 @@ +let amount_input = document.querySelector("input[name=amount]"); +let price_input = document.querySelector("input[name=price]"); + +amount_input.addEventListener("change", e => { + if (e.target.value) { + price_input.value = ""; + } +}); + +price_input.addEventListener("change", e => { + if (e.target.value) { + amount_input.value = ""; + } +}); \ No newline at end of file diff --git a/pass/public/styles/admin/index.css b/pass/public/styles/admin/index.css index 28890d16f5a1857fa154143823c1943ceb7625b0..03bf180a1260ab3a989db468fa4e5945bd1b5ac8 100644 --- a/pass/public/styles/admin/index.css +++ b/pass/public/styles/admin/index.css @@ -1 +1 @@ -div#admin-container div#action-container{display:flex;flex-wrap:wrap;margin-top:2rem}div#admin-container div#action-container>a{display:flex;gap:.5rem;align-items:center;border:1px solid #ef7700;padding:1rem;border-radius:10px;text-decoration:none;color:inherit;cursor:pointer}div#admin-container div#action-container>a>img{width:2em} \ No newline at end of file +div#admin-container div#action-container{display:flex;gap:1rem;flex-wrap:wrap;margin-top:2rem}div#admin-container div#action-container>a{display:flex;gap:.5rem;align-items:center;border:1px solid #ef7700;padding:1rem;border-radius:10px;text-decoration:none;color:inherit;cursor:pointer}div#admin-container div#action-container>a>img{width:2em} \ No newline at end of file diff --git a/pass/public/styles/admin/index.less b/pass/public/styles/admin/index.less index 9c7074db4cd1d4c4231c457a9ced409136ef035c..4ffe815d97b74164885500c0c7ad27ae53d8398c 100644 --- a/pass/public/styles/admin/index.less +++ b/pass/public/styles/admin/index.less @@ -3,6 +3,7 @@ div#admin-container { div#action-container { display: flex; + gap: 1rem; flex-wrap: wrap; margin-top: 2rem; > a { diff --git a/pass/public/styles/admin/key-management.css b/pass/public/styles/admin/key-management.css new file mode 100644 index 0000000000000000000000000000000000000000..5e8c8297bb63e32dc0e231aeb7e48d5c5115cf8c --- /dev/null +++ b/pass/public/styles/admin/key-management.css @@ -0,0 +1 @@ +form{display:grid;gap:1rem}form .input-group{display:grid;gap:.5rem;grid-template-columns:max-content}form .input-group label{font-weight:bold}form .input-group input{padding:.25rem .5rem;border-radius:5px}form button.button{padding:.5rem} \ No newline at end of file diff --git a/pass/public/styles/admin/key-management.less b/pass/public/styles/admin/key-management.less new file mode 100644 index 0000000000000000000000000000000000000000..b4970d7a1bbfc46bfc650da479edb214516add51 --- /dev/null +++ b/pass/public/styles/admin/key-management.less @@ -0,0 +1,20 @@ +form { + display: grid; + gap: 1rem; + .input-group { + display: grid; + gap: 0.5rem; + grid-template-columns: max-content; + label { + font-weight: bold; + } + input { + padding: 0.25rem 0.5rem; + border-radius: 5px; + } + } + + button.button { + padding: 0.5rem; + } +} diff --git a/pass/public/styles/admin/key-overview.css b/pass/public/styles/admin/key-overview.css new file mode 100644 index 0000000000000000000000000000000000000000..2fc4a7bae848fcd3176071ad2af2fe0936ac941b --- /dev/null +++ b/pass/public/styles/admin/key-overview.css @@ -0,0 +1 @@ +#admin-container{display:grid;gap:1rem}#admin-container .breadcrumps{margin:0}#admin-container #key-info{display:grid;gap:.5rem}#admin-container #key-info h2{margin:0}#admin-container #key-info .charge-success{color:green}#admin-container #key-info #charges{display:flex;gap:1rem;flex-wrap:wrap}#admin-container #key-info #charges .charge{display:grid;gap:.5rem;place-items:center;border:1px solid #777;width:max-content;padding:1rem;border-radius:5px}#admin-container #key-info #charges .charge .charge-amount{font-size:2rem}#admin-container form#charge-form{display:grid;grid-template-columns:repeat(3, auto);gap:1rem;place-items:center}#admin-container form#charge-form .heading,#admin-container form#charge-form .hint,#admin-container form#charge-form .button,#admin-container form#charge-form #errors{grid-column:span 3}@media (max-width:530px){#admin-container form#charge-form{grid-template-columns:auto}#admin-container form#charge-form .heading,#admin-container form#charge-form .hint,#admin-container form#charge-form .button,#admin-container form#charge-form #errors{grid-column:1}}#admin-container form#charge-form h3{margin:0}#admin-container form#charge-form #errors{list-style-type:none;padding:0;color:red}#admin-container form#charge-form .button{padding:.5rem;cursor:pointer}#admin-container form#charge-form .input-group{display:grid;place-items:center;gap:.5rem}#admin-container form#charge-form .input-group>label{font-weight:bold}#admin-container form#charge-form .input-group input{padding:.25rem .5rem;border-radius:5px} \ No newline at end of file diff --git a/pass/public/styles/admin/key-overview.less b/pass/public/styles/admin/key-overview.less new file mode 100644 index 0000000000000000000000000000000000000000..674bf101bd26c76c4c8f0e91de5f3a28fad7ed36 --- /dev/null +++ b/pass/public/styles/admin/key-overview.less @@ -0,0 +1,80 @@ +#admin-container { + display: grid; + gap: 1rem; + .breadcrumps { + margin: 0; + } + #key-info { + display: grid; + gap: 0.5rem; + h2 { + margin: 0; + } + .charge-success { + color: green; + } + #charges { + display: flex; + gap: 1rem; + flex-wrap: wrap; + .charge { + display: grid; + gap: 0.5rem; + place-items: center; + border: 1px solid #777; + width: max-content; + padding: 1rem; + border-radius: 5px; + .charge-amount { + font-size: 2rem; + } + } + } + } + form#charge-form { + display: grid; + grid-template-columns: repeat(3, auto); + + gap: 1rem; + place-items: center; + .heading, + .hint, + .button, + #errors { + grid-column: span 3; + } + @media (max-width: 530px) { + grid-template-columns: auto; + .heading, + .hint, + .button, + #errors { + grid-column: 1; + } + } + h3 { + margin: 0; + } + #errors { + list-style-type: none; + padding: 0; + color: red; + } + .button { + padding: 0.5rem; + cursor: pointer; + } + .input-group { + display: grid; + place-items: center; + gap: 0.5rem; + > label { + font-weight: bold; + } + input { + padding: 0.25rem 0.5rem; + border-radius: 5px; + } + } + } +} diff --git a/pass/routes/admin/index.js b/pass/routes/admin/index.js index 98f9d8e10946edc42dc9cba08bde15704d6940b8..2ca7f2ecb1c31c3460f6cc8b1dc162c5338471b5 100644 --- a/pass/routes/admin/index.js +++ b/pass/routes/admin/index.js @@ -8,6 +8,7 @@ const { matchedData, body, query, + oneOf } = require("express-validator"); const OrderReceipt = require("../../app/pdf/OrderReceipt"); const crypto = require("crypto"); @@ -15,6 +16,7 @@ const Payment = require("../../app/Payment"); const PaymentReference = require("../../app/PaymentReference"); const Receipt = require("../../app/Receipt"); const Cash = require("../../app/payment_processor/Cash"); +const Key = require("../../app/Key"); router.use((req, res, next) => { let cookie_path = new URL(res.locals.baseDir).pathname.replace(/(\/)?$/, "/admin"); @@ -320,4 +322,76 @@ router.post( } ); +router.get("/key", (req, res) => { + res.render("admin/key/index"); +}); + +router.post("/key", (req, res) => { + Key.GET_KEY(req.body.key, false).then(key => { + return res.redirect(`${res.baseDir}/admin/key/${key.get_key()}`); + }); +}) + +router.use("/key/:key", (req, res, next) => { + if (req.query.charge_success) { + res.locals.success = true; + } + Key.GET_KEY(req.params.key, false).then(key => { + res.locals.key = key; + next(); + }); +}); + +router.get("/key/:key", (req, res) => { + res.render("admin/key/overview"); +}); +router.post("/key/:key", + oneOf([ + body("amount").isInt({ gt: 0 }), + body("price").isCurrency({ allow_negatives: false, allow_decimal: true }) + ]), + (req, res) => { + let queryData = matchedData(req, { location: ["body"] }); + let amount = queryData.amount; + if (!amount) { + // If amount is not given but price is + // Calculate amount from price + let price = queryData.price; + if (price) { + amount = Math.ceil(price / config.get("price.per_token")); + } + } + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.locals.errors = errors.errors; + res.render("admin/key/overview"); + return; + } + return PaymentReference.CREATE_NEW_REQUEST(amount, res.locals.key.get_key(), undefined, true) + .then(payment_reference => payment_reference.chargeKey()) + .then(() => { + res.redirect(`${res.baseDir}/admin/key/${res.locals.key.get_key()}?charge_success=true`); + }); + }); + +router.post("/key/:key/remove-charge", + body("payment_reference").notEmpty().isInt().toInt(), + (req, res) => { + let queryData = matchedData(req, { location: ["body"] }); + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.redirect(`${res.baseDir}/admin/key/${res.locals.key.get_key()}?charge_success=true`); + } + let payment_reference_id = queryData.payment_reference; + /** @type {Key} */ + let key = res.locals.key; + let payment_reference_charge = key.get_charge(payment_reference_id); + return Key.GET_KEY(key.get_key(), true).then(writable_key => { + writable_key.discharge_key(payment_reference_charge, payment_reference_id); + return writable_key.save(); + }).then(() => { + res.redirect(`${res.baseDir}/admin/key/${res.locals.key.get_key()}?charge_success=true`); + }); + }); + module.exports = router; diff --git a/pass/views/admin/index.ejs b/pass/views/admin/index.ejs index 3b9e912eb17275e295a045ce697a94c0ddac53ef..f9b301aab61ffc493817d095a3f0a1f9b21e1a0a 100644 --- a/pass/views/admin/index.ejs +++ b/pass/views/admin/index.ejs @@ -14,6 +14,10 @@ <img src="<%= baseDir %>/images/invoice.svg" alt="Geldschein"> <div><%= req.t("index.actions.receipt", {ns: "admin"}) _%></div> </a> + <a id="action-keymanagement" href="<%= baseDir %>/admin/key"> + <img src="<%= baseDir %>/images/key-icon.svg" alt="Geldschein"> + <div><%= req.t("index.actions.keymanagement", {ns: "admin"}) _%></div> + </a> </div> </div> <%- include('../templates/page_footer'); -%> \ No newline at end of file diff --git a/pass/views/admin/key/index.ejs b/pass/views/admin/key/index.ejs new file mode 100644 index 0000000000000000000000000000000000000000..d716519ab667e8bdd0ceb3efc3eca3fcbc770802 --- /dev/null +++ b/pass/views/admin/key/index.ejs @@ -0,0 +1,19 @@ +<%- include('../../templates/page_header', {css: [`${baseDir}/styles/admin/base.css`, `${baseDir}/styles/admin/key-management.css`], js: []}); %> +<div id="admin-container"> + <ul class="breadcrumps"> + <li> + <a href="<%= baseDir _%>/admin/"> + <%= req.t("breadcrumps.overview", {ns: "admin"}) _%> + </a> + </li> + <li><%= req.t("breadcrumps.key-management", {ns: "admin"}) _%></li> + </ul> + <form method="POST"> + <div class="input-group"> + <label for="key"><%= req.t("key.key-input.label", {ns: "admin"}) _%></label> + <input type="text" name="key" id="key" size="36" placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"> + </div> + <button class="button"><%= req.t("key.key-input.submit", {ns: "admin"}) _%></button> + </form> +</div> +<%- include('../../templates/page_footer'); -%> \ No newline at end of file diff --git a/pass/views/admin/key/overview.ejs b/pass/views/admin/key/overview.ejs new file mode 100644 index 0000000000000000000000000000000000000000..3d90d9d3a02bcb893f96b548b18f249bedf93192 --- /dev/null +++ b/pass/views/admin/key/overview.ejs @@ -0,0 +1,52 @@ +<%- include('../../templates/page_header', {css: [`${baseDir}/styles/admin/base.css`, `${baseDir}/styles/admin/key-overview.css`], js: [`${baseDir}/js/admin/key-overview.js`]}); %> +<div id="admin-container"> + <ul class="breadcrumps"> + <li> + <a href="<%= baseDir _%>/admin/"> + <%= req.t("breadcrumps.overview", {ns: "admin"}) _%> + </a> + </li> + <li><%= req.t("breadcrumps.key-management", {ns: "admin"}) _%></li> + </ul> + <div id="key-info"> + <h1><%= key.get_key() _%></h1> + <h2><%= req.t("key.key-overview.charge", {ns: "admin", token: key.get_charge()}) _%></h2> + <%_ if(typeof success !== "undefined") { %> + <div class="charge-success"><%= req.t("key.key-overview.charge-success", {ns: "admin"}) _%></div> + <%_ } _%> + <div id="charges"> + <%_ for(let i = 0; i < key.get_charge_orders().length; i++) { _%> + <div class="charge"> + <div class="charge-amount"><%= key.get_charge_orders()[i].amount _%></div> + <div><%= req.t("key.key-overview.expiration", {ns: "admin", expiration: key.get_charge_orders()[i].expiration.format("DD.MM.YYYY HH:mm:ss")}) _%></div> + <form method="POST" action="<%= `${baseDir}/admin/key/${key.get_key()}/remove-charge`%>"> + <input type="hidden" name="payment_reference" value="<%= key.get_charge_orders()[i].payment_reference_id _%>"> + <button type="submit"><%= req.t("key.key-overview.delete", {ns: "admin"}) _%></button> + </form> + </div> + <%_ } _%> + </div> + </div> + <form method="POST" id="charge-form"> + <h3 class="heading"><%= req.t("key.key-overview.charge-form.heading", {ns: "admin"}) _%></h3> + <div class="hint"><%= req.t("key.key-overview.charge-form.hint", {ns: "admin"}) _%></div> + <%_ if(typeof errors !== "undefined") { _%> + <ul id="errors"> + <%_ for(let i = 0; i < errors.length; i++) { _%> + <li class="error"><%= errors[i].param + ": " + errors[i].msg _%></li> + <%_ } _%> + </ul> + <%_ } _%> + <div class="input-group"> + <label for="amount"><%= req.t("key.key-overview.charge-form.amount.label", {ns: "admin"}) _%></label> + <input type="number" name="amount" id="amount" min="0" step="1" placeholder="1000"> + </div> + <div>oder</div> + <div class="input-group"> + <label for="price"><%= req.t("key.key-overview.charge-form.price.label", {ns: "admin"}) _%></label> + <input type="number" name="price" id="price" min="0" step="0.01" placeholder="10.00"> + </div> + <button class="button" type="submit"><%= req.t("key.key-overview.charge-form.submit", {ns: "admin"}) _%></buton> + </form> +</div> +<%- include('../../templates/page_footer'); -%> \ No newline at end of file