From c55f8f8b3a5bb82d8ffe43c8596af538361c1e41 Mon Sep 17 00:00:00 2001 From: Thomas Ruoff Date: Thu, 22 Oct 2020 00:40:09 +0200 Subject: [PATCH] Admin page for bill wit iron-session (#13) --- lib/authenticate.ts | 28 +++ lib/session.ts | 17 ++ package-lock.json | 71 ++++++ package.json | 1 + pages/admin/booking/[uuid]/bill.tsx | 320 ++++++++++++++++++++++++++++ pages/api/booking/[uuid]/bill.ts | 16 +- pages/booking/[uuid]/bill.tsx | 304 -------------------------- public/favicon.ico | Bin 15086 -> 9662 bytes 8 files changed, 446 insertions(+), 311 deletions(-) create mode 100644 lib/authenticate.ts create mode 100644 lib/session.ts create mode 100644 pages/admin/booking/[uuid]/bill.tsx delete mode 100644 pages/booking/[uuid]/bill.tsx diff --git a/lib/authenticate.ts b/lib/authenticate.ts new file mode 100644 index 0000000..0880755 --- /dev/null +++ b/lib/authenticate.ts @@ -0,0 +1,28 @@ +import { IncomingMessage, ServerResponse } from 'http' + +export default function authenticate( + req: IncomingMessage, + res: ServerResponse +) { + const authHeader = req.headers.authorization + + if (!authHeader) { + res.setHeader('WWW-Authenticate', 'Basic') + res.statusCode = 401 + return null + } + + const [username, password] = Buffer.from(authHeader.split(' ')[1], 'base64') + .toString() + .split(':') + + // FIXME: pull admin password from env + if (username === 'admin' || password === 'secret') { + return { username: 'admin', role: 'admin' } + } + + res.setHeader('WWW-Authenticate', 'Basic') + res.statusCode = 401 + res.end() + return null +} diff --git a/lib/session.ts b/lib/session.ts new file mode 100644 index 0000000..202b228 --- /dev/null +++ b/lib/session.ts @@ -0,0 +1,17 @@ +import { withIronSession } from 'next-iron-session' + +const SESSION_SECRET = + process.env.SESSION_SECRET || 'dev-env-default-secret-991823723' + +export default function withSession(handler) { + return withIronSession(handler, { + password: SESSION_SECRET, + cookieName: 'pfadi-bussle-cookie', + cookieOptions: { + // the next line allows to use the session in non-https environements like + // Next.js dev mode (http://localhost:3000) + secure: process.env.NODE_ENV === 'production', + path: '/admin', + }, + }) +} diff --git a/package-lock.json b/package-lock.json index fc5b14f..84b82e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1345,6 +1345,35 @@ "@hapi/hoek": "^9.0.0" } }, + "@hapi/b64": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/b64/-/b64-5.0.0.tgz", + "integrity": "sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==", + "requires": { + "@hapi/hoek": "9.x.x" + } + }, + "@hapi/boom": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.0.tgz", + "integrity": "sha512-4nZmpp4tXbm162LaZT45P7F7sgiem8dwAh2vHWT6XX24dozNjGMg6BvKCRvtCUcmcXqeMIUqWN8Rc5X8yKuROQ==", + "requires": { + "@hapi/hoek": "9.x.x" + } + }, + "@hapi/bourne": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", + "integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==" + }, + "@hapi/cryptiles": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/cryptiles/-/cryptiles-5.1.0.tgz", + "integrity": "sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==", + "requires": { + "@hapi/boom": "9.x.x" + } + }, "@hapi/formula": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz", @@ -1355,6 +1384,18 @@ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz", "integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw==" }, + "@hapi/iron": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@hapi/iron/-/iron-6.0.0.tgz", + "integrity": "sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==", + "requires": { + "@hapi/b64": "5.x.x", + "@hapi/boom": "9.x.x", + "@hapi/bourne": "2.x.x", + "@hapi/cryptiles": "5.x.x", + "@hapi/hoek": "9.x.x" + } + }, "@hapi/joi": { "version": "17.1.1", "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz", @@ -3422,6 +3463,11 @@ "wrap-ansi": "^6.2.0" } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3548,6 +3594,11 @@ "safe-buffer": "~5.1.1" } }, + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -5169,6 +5220,15 @@ "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", "dev": true }, + "iron-store": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iron-store/-/iron-store-1.3.0.tgz", + "integrity": "sha512-Ics0Xy/1TTjOhZFLkarOpWfXp4TJ7foiKBFC/aXJH1bKwafNKrEhrHNeHyA/Jqx57tlhpIbGoNC3RUB+rRIz8Q==", + "requires": { + "@hapi/iron": "^6.0.0", + "clone": "^2.1.2" + } + }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -7647,6 +7707,17 @@ } } }, + "next-iron-session": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/next-iron-session/-/next-iron-session-4.1.9.tgz", + "integrity": "sha512-8AmKWSDXEHc35364zY4y4H0xWLA3f7pqFGBubiuDunxvh159am7sonMoFDPJ7y38UQ8Qa1AHrKMM8qgTGeuhjg==", + "requires": { + "clone": "^2.1.2", + "cookie": "^0.4.1", + "debug": "^4.1.1", + "iron-store": "^1.3.0" + } + }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", diff --git a/package.json b/package.json index 5583dbb..71056e2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "ics": "^2.25.0", "mongoose": "^5.10.8", "next": "^9.5.4", + "next-iron-session": "^4.1.9", "react": "16.13.1", "react-calendar": "^3.1.0", "react-dom": "16.13.1", diff --git a/pages/admin/booking/[uuid]/bill.tsx b/pages/admin/booking/[uuid]/bill.tsx new file mode 100644 index 0000000..f10062d --- /dev/null +++ b/pages/admin/booking/[uuid]/bill.tsx @@ -0,0 +1,320 @@ +import { GetServerSideProps, NextApiHandler, NextApiRequest } from 'next' +import React, { useEffect, useState } from 'react' +import Footer from '../../../../components/footer' +import Header from '../../../../components/header' +import Input from '../../../../components/input' +import Select from '../../../../components/select' +import { AdditionalCost, BillDocument } from '../../../../db/bill' +import { BookingDocument } from '../../../../db/booking' +import { BILL_STATUS, MILAGE_TARIFS } from '../../../../db/enums' +import { getBookingByUUID, getMilageMax } from '../../../../db/index' +import { dateFormatFrontend } from '../../../../helpers/date' +import { getBillTotal } from '../../../../helpers/bill' +import { getBookingStatus } from '../../../../helpers/booking' +import authenticate from '../../../../lib/authenticate' +import withSession from '../../../../lib/session' + +export const getServerSideProps: GetServerSideProps = withSession( + async ({ req, res, params }) => { + const { uuid: uuids } = params + + const authenticatedUser = authenticate(req, res) + if (!authenticatedUser) { + // TODO: not sure if needed + req?.session.destroy() + return { props: {} } + } + + req.session.set('user', authenticatedUser) + await req.session.save() + + const uuid = Array.isArray(uuids) ? uuids[0] : uuids + const booking = await getBookingByUUID(uuid) + + if (!booking) { + res.statusCode = 404 + res.end() + return { props: {} } + } + + const milageMax = await getMilageMax() + await booking.populate('booker').populate('bill').execPopulate() + + // TODO: hack, not sure why _id is not serilizable + const bookingJSON = JSON.parse(JSON.stringify(booking.toJSON())) + return { + props: { booking: bookingJSON, milageMax }, + } + } +) + +const milageTarifOptions = Object.values(MILAGE_TARIFS).map((tarif) => { + return ( + + ) +}) + +const bookingStatusOptions = Object.values(BILL_STATUS).map((status) => { + return ( + + ) +}) + +function getTarifLabel(tarif: MILAGE_TARIFS) { + switch (tarif) { + case MILAGE_TARIFS.EXTERN: + return 'Extern' + case MILAGE_TARIFS.INTERN: + return 'Intern' + case MILAGE_TARIFS.NOCHARGE: + return 'Frei von Kosten' + default: + return 'Keine' + } +} + +function getBillStatusLabel(status: BILL_STATUS) { + switch (status) { + case BILL_STATUS.UNINVOICED: + return 'Nicht gestellt' + case BILL_STATUS.INVOICED: + return 'Gestellt' + case BILL_STATUS.PAID: + return 'Bezahlt' + default: + return 'Unbekannt!!!' + } +} + +async function saveBill( + booking: BookingDocument, + bill: { + milageStart: number + milageEnd: number + milage?: number + tarif: MILAGE_TARIFS + additionalCosts: AdditionalCost[] + status: BILL_STATUS + } +): Promise { + const response = await fetch(`/api/booking/${booking.uuid}/bill`, { + method: booking.bill?._id ? 'PATCH' : 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + }, + referrerPolicy: 'no-referrer', + body: JSON.stringify(bill), + }) + return response.json() +} + +export default function BillPage({ + booking: bookingProp, + milageMax, +}: { + booking: BookingDocument + milageMax: number +}) { + const [booking, setBooking] = useState(bookingProp) + const [milageStart, setMilageStart] = useState( + booking?.bill?.milageStart || milageMax + ) + const [milageEnd, setMilageEnd] = useState(booking?.bill?.milageEnd) + const [tarif, setTarif] = useState( + booking?.bill?.tarif || MILAGE_TARIFS.EXTERN + ) + const [status, setStatus] = useState(booking?.bill?.status) + const [additionalCosts, setAdditionalCosts] = useState([]) + const [storingInProgress, setStoringInProgress] = useState(false) + const [storingError, setStoringError] = useState(null) + const milage = + (0 < milageStart && milageStart < milageEnd && milageEnd - milageStart) || 0 + const total = getBillTotal({ tarif, milage, additionalCosts }) + + // in case the props change, update the internal state + useEffect(() => setBooking(bookingProp), [bookingProp]) + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setStoringInProgress(true) + setStoringError(null) + + try { + const bill = await saveBill(booking, { + milageStart, + milageEnd, + milage, + tarif, + status, + additionalCosts, + }) + + booking.bill = bill + setBooking(booking) + } catch (error) { + setStoringError('Buchung konnte nicht gespeichert werden!') + console.error('Failed to store booking', error) + } + setStoringInProgress(false) + } + + const onAddAdditionalCost = function ( + event: React.MouseEvent + ) { + event.preventDefault() + setAdditionalCosts([...additionalCosts, { name: '', value: 0 }]) + } + + const onRemoveAdditionalCost = function ( + event: React.MouseEvent, + index: number + ) { + event.preventDefault() + setAdditionalCosts([ + ...additionalCosts.slice(0, index), + ...additionalCosts.slice(index + 1), + ]) + } + + return ( +
+
+
+

Abrechnung

+ {booking && ( +
+
+ Buchungszeitraum:{' '} + {dateFormatFrontend(new Date(booking.startDate))} -{' '} + {dateFormatFrontend(new Date(booking.endDate))} +
+
+ Bucher: {booking.booker.name} +
+
+ Buchungsstatus: {getBookingStatus(booking)} +
+
+ >) => + setMilageStart(Number(e.target.value)) + } + /> + >) => + setMilageEnd(Number(e.target.value)) + } + /> + + +
+ + +
+ {additionalCosts.map((_, index) => { + return ( + <> +
+ + +
+
+ { + const newAdditonalCosts = [...additionalCosts] + newAdditonalCosts[index] = { + value: newAdditonalCosts[index].value, + name: event.target.value, + } + setAdditionalCosts(newAdditonalCosts) + }} + /> + { + const newAdditonalCosts = [...additionalCosts] + newAdditonalCosts[index] = { + name: newAdditonalCosts[index].name, + value: Number(event.target.value), + } + setAdditionalCosts(newAdditonalCosts) + }} + /> +
+ + ) + })} + +
+ + {storingError && ( +
{storingError}
+ )} + +
+ )} +
+ +
+
+ ) +} diff --git a/pages/api/booking/[uuid]/bill.ts b/pages/api/booking/[uuid]/bill.ts index 3a151dd..bef58f8 100644 --- a/pages/api/booking/[uuid]/bill.ts +++ b/pages/api/booking/[uuid]/bill.ts @@ -1,18 +1,20 @@ -import { NextApiRequest, NextApiResponse } from 'next' import { BillDocument } from '../../../../db/bill' import { createBill, patchBill } from '../../../../db/index' +import withSession from '../../../../lib/session' -export default async function userHandler( - req: NextApiRequest, - res: NextApiResponse -) { +export default withSession(async function billHandler(req, res) { const { method, query: { uuid: uuids }, } = req - const bookingUUID = Array.isArray(uuids) ? uuids[0] : uuids + const user = req?.session.get('user') + if (!user || user.role !== 'admin') { + res.status(401).end('Your are unauthorized. Best to move along...') + return + } + let bill: BillDocument switch (method) { @@ -40,4 +42,4 @@ export default async function userHandler( res.setHeader('Allow', ['POST']) res.status(405).end(`Method ${method} Not Allowed`) } -} +}) diff --git a/pages/booking/[uuid]/bill.tsx b/pages/booking/[uuid]/bill.tsx deleted file mode 100644 index 3066d43..0000000 --- a/pages/booking/[uuid]/bill.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import { GetServerSideProps } from 'next' -import React, { useEffect, useState } from 'react' -import Footer from '../../../components/footer' -import Header from '../../../components/header' -import Input from '../../../components/input' -import Select from '../../../components/select' -import { AdditionalCost, BillDocument } from '../../../db/bill' -import { BookingDocument } from '../../../db/booking' -import { BILL_STATUS, MILAGE_TARIFS } from '../../../db/enums' -import { getBookingByUUID, getMilageMax } from '../../../db/index' -import { dateFormatFrontend } from '../../../helpers/date' -import { getBillTotal } from '../../../helpers/bill' -import { getBookingStatus } from '../../../helpers/booking' - -const milageTarifOptions = Object.values(MILAGE_TARIFS).map((tarif) => { - return ( - - ) -}) - -const bookingStatusOptions = Object.values(BILL_STATUS).map((status) => { - return ( - - ) -}) - -export const getServerSideProps: GetServerSideProps = async (context) => { - const { - res, - params: { uuid: uuids }, - } = context - const uuid = Array.isArray(uuids) ? uuids[0] : uuids - const booking = await getBookingByUUID(uuid) - await booking.populate('booker').populate('bill').execPopulate() - - if (!booking) { - res.statusCode = 404 - res.end() - return { props: {} } - } - - const milageMax = await getMilageMax() - - // TODO: hack, not sure why _id is not serilizable - const bookingJSON = JSON.parse(JSON.stringify(booking.toJSON())) - return { - props: { booking: bookingJSON, milageMax }, - } -} - -function getTarifLabel(tarif: MILAGE_TARIFS) { - switch (tarif) { - case MILAGE_TARIFS.EXTERN: - return 'Extern' - case MILAGE_TARIFS.INTERN: - return 'Intern' - case MILAGE_TARIFS.NOCHARGE: - return 'Frei von Kosten' - default: - return 'Keine' - } -} - -function getBillStatusLabel(status: BILL_STATUS) { - switch (status) { - case BILL_STATUS.UNINVOICED: - return 'Nicht gestellt' - case BILL_STATUS.INVOICED: - return 'Gestellt' - case BILL_STATUS.PAID: - return 'Bezahlt' - default: - return 'Unbekannt!!!' - } -} - -async function saveBill( - booking: BookingDocument, - bill: { - milageStart: number - milageEnd: number - milage?: number - tarif: MILAGE_TARIFS - additionalCosts: AdditionalCost[] - status: BILL_STATUS - } -): Promise { - const response = await fetch(`/api/booking/${booking.uuid}/bill/`, { - method: booking.bill?._id ? 'PATCH' : 'POST', - mode: 'cors', - cache: 'no-cache', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - }, - referrerPolicy: 'no-referrer', - body: JSON.stringify(bill), - }) - return response.json() -} - -export default function BillPage({ - booking: bookingProp, - milageMax, -}: { - booking: BookingDocument - milageMax: number -}) { - const [booking, setBooking] = useState(bookingProp) - const [milageStart, setMilageStart] = useState( - booking.bill?.milageStart || milageMax - ) - const [milageEnd, setMilageEnd] = useState(booking.bill?.milageEnd) - const [tarif, setTarif] = useState( - booking.bill?.tarif || MILAGE_TARIFS.EXTERN - ) - const [status, setStatus] = useState(booking.bill?.status) - const [additionalCosts, setAdditionalCosts] = useState([]) - const [storingInProgress, setStoringInProgress] = useState(false) - const [storingError, setStoringError] = useState(null) - const milage = - (0 < milageStart && milageStart < milageEnd && milageEnd - milageStart) || 0 - const total = getBillTotal({ tarif, milage, additionalCosts }) - - // in case the props change, update the internal state - useEffect(() => setBooking(bookingProp), [bookingProp]) - - const onSubmit = async (event: React.FormEvent) => { - event.preventDefault() - setStoringInProgress(true) - setStoringError(null) - - try { - const bill = await saveBill(booking, { - milageStart, - milageEnd, - milage, - tarif, - status, - additionalCosts, - }) - - booking.bill = bill - setBooking(booking) - } catch (error) { - setStoringError('Buchung konnte nicht gespeichert werden!') - console.error('Failed to store booking', error) - } - setStoringInProgress(false) - } - - const onAddAdditionalCost = function ( - event: React.MouseEvent - ) { - event.preventDefault() - setAdditionalCosts([...additionalCosts, { name: '', value: 0 }]) - } - - const onRemoveAdditionalCost = function ( - event: React.MouseEvent, - index: number - ) { - event.preventDefault() - setAdditionalCosts([ - ...additionalCosts.slice(0, index), - ...additionalCosts.slice(index + 1), - ]) - } - - return ( -
-
-
-

Abrechnung

-
-
- Buchungszeitraum:{' '} - {dateFormatFrontend(new Date(booking.startDate))} -{' '} - {dateFormatFrontend(new Date(booking.endDate))} -
-
- Bucher: {booking.booker.name} -
-
- Buchungsstatus: {getBookingStatus(booking)} -
-
- >) => - setMilageStart(Number(e.target.value)) - } - /> - >) => - setMilageEnd(Number(e.target.value)) - } - /> - - -
- - -
- {additionalCosts.map((_, index) => { - return ( - <> -
- - -
-
- { - const newAdditonalCosts = [...additionalCosts] - newAdditonalCosts[index] = { - value: newAdditonalCosts[index].value, - name: event.target.value, - } - setAdditionalCosts(newAdditonalCosts) - }} - /> - { - const newAdditonalCosts = [...additionalCosts] - newAdditonalCosts[index] = { - name: newAdditonalCosts[index].name, - value: Number(event.target.value), - } - setAdditionalCosts(newAdditonalCosts) - }} - /> -
- - ) - })} - -
- - {storingError && ( -
{storingError}
- )} - -
-
- -
-
- ) -} diff --git a/public/favicon.ico b/public/favicon.ico index 4965832f2c9b0605eaa189b7c7fb11124d24e48a..1e826292d077b73b751179a6d5da9e4f2c7a8109 100644 GIT binary patch literal 9662 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