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 ( + + {getTarifLabel(tarif)} + + ) +}) + +const bookingStatusOptions = Object.values(BILL_STATUS).map((status) => { + return ( + + {getBillStatusLabel(status)} + + ) +}) + +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)) + } + /> + + setTarif(e.target.value as MILAGE_TARIFS)} + > + {milageTarifOptions} + + + + + + + Zusätzliche Kosten + + {additionalCosts.map((_, index) => { + return ( + <> + + + onRemoveAdditionalCost(event, index) + } + title="Entfernen" + > + - + + {`Kostenpunkt ${ + index + 1 + }`} + + + { + 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) + }} + /> + + > + ) + })} + + + setStatus(e.target.value as BILL_STATUS)} + > + {bookingStatusOptions} + + {storingError && ( + {storingError} + )} + + Rechnung {booking.bill?._id ? 'Updaten' : 'Erstellen'} + + + )} + + + + + ) +} 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 ( - - {getTarifLabel(tarif)} - - ) -}) - -const bookingStatusOptions = Object.values(BILL_STATUS).map((status) => { - return ( - - {getBillStatusLabel(status)} - - ) -}) - -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)) - } - /> - - setTarif(e.target.value as MILAGE_TARIFS)} - > - {milageTarifOptions} - - - - + - - Zusätzliche Kosten - - {additionalCosts.map((_, index) => { - return ( - <> - - onRemoveAdditionalCost(event, index)} - title="Entfernen" - > - - - - {`Kostenpunkt ${ - index + 1 - }`} - - - { - 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) - }} - /> - - > - ) - })} - - - setStatus(e.target.value as BILL_STATUS)} - > - {bookingStatusOptions} - - {storingError && ( - {storingError} - )} - - Rechnung {booking.bill?._id ? 'Updaten' : 'Erstellen'} - - - - - - - ) -} diff --git a/public/favicon.ico b/public/favicon.ico index 4965832..1e82629 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ