diff --git a/backend/pkg/db/events/events.go b/backend/pkg/db/events/events.go index d9ab01c..74755f7 100644 --- a/backend/pkg/db/events/events.go +++ b/backend/pkg/db/events/events.go @@ -150,3 +150,9 @@ func UserPending(userName string) (int, error) { return result.Count, nil } } + +func Delete(eventId int) error { + _, err := db.DB.Exec("DELETE FROM EVENTS WHERE id = ?", eventId) + + return err +} diff --git a/backend/pkg/db/users/users.go b/backend/pkg/db/users/users.go index bac49ea..fb954b4 100644 --- a/backend/pkg/db/users/users.go +++ b/backend/pkg/db/users/users.go @@ -4,8 +4,10 @@ import ( "fmt" "time" + "github.com/google/uuid" cache "github.com/jfarleyx/go-simple-cache" "github.com/johannesbuehl/golunteer/backend/pkg/db" + "golang.org/x/crypto/bcrypt" ) type User struct { @@ -17,6 +19,16 @@ type User struct { var c *cache.Cache +// hashes a password +func hashPassword(password string) ([]byte, error) { + return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) +} + +// validates a password against the password-rules +func ValidatePassword(password string) bool { + return len(password) >= 12 && len(password) <= 64 +} + func Get() (map[string]User, error) { if users, hit := c.Get("users"); !hit { refresh() @@ -27,6 +39,37 @@ func Get() (map[string]User, error) { } } +type UserAdd struct { + UserName string `json:"userName" validate:"required" db:"userName"` + Password string `json:"password" validate:"required,min=8"` + Admin bool `json:"admin" db:"admin"` +} + +func Add(user UserAdd) error { + // try to hash the password + if hash, err := hashPassword(user.Password); err != nil { + return err + } else { + insertUser := struct { + UserAdd + Password []byte `db:"password"` + TokenID string `db:"tokenID"` + }{ + UserAdd: user, + Password: hash, + TokenID: uuid.NewString(), + } + + if _, err := db.DB.NamedExec("INSERT INTO USERS (name, password, admin, tokenID) VALUES (:userName, :password, :admin, :tokenID)", insertUser); err != nil { + return err + } else { + refresh() + + return nil + } + } +} + func refresh() { // get the usersRaw from the database var usersRaw []User diff --git a/backend/pkg/router/events.go b/backend/pkg/router/events.go index 8bf63a6..3b980d7 100644 --- a/backend/pkg/router/events.go +++ b/backend/pkg/router/events.go @@ -88,3 +88,20 @@ func getEventsUserPending(args HandlerArgs) responseMessage { return response } + +func deleteEvent(args HandlerArgs) responseMessage { + response := responseMessage{} + + // check for admin + if !args.User.Admin { + response.Status = fiber.StatusForbidden + + // -1 can't be valid + } else if eventId := args.C.QueryInt("id", -1); eventId == -1 { + response.Status = fiber.StatusBadRequest + } else if err := events.Delete(eventId); err != nil { + response.Status = fiber.StatusInternalServerError + } + + return response +} diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go index 613aa3b..88db1fb 100644 --- a/backend/pkg/router/router.go +++ b/backend/pkg/router/router.go @@ -80,9 +80,14 @@ func init() { "events/user/pending": getEventsUserPending, "tasks": getTasks, }, - "POST": {"events": postEvent}, - "PATCH": {}, - "DELETE": {}, + "POST": { + "events": postEvent, + "users": postUser, + }, + "PATCH": {}, + "DELETE": { + "event": deleteEvent, + }, } // handle specific requests special diff --git a/backend/pkg/router/user.go b/backend/pkg/router/user.go index a36227a..6d2088d 100644 --- a/backend/pkg/router/user.go +++ b/backend/pkg/router/user.go @@ -1,13 +1,36 @@ package router -import "golang.org/x/crypto/bcrypt" +import ( + "github.com/gofiber/fiber/v2" + "github.com/johannesbuehl/golunteer/backend/pkg/db/users" +) -// hashes a password -func hashPassword(password string) ([]byte, error) { - return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) -} +func postUser(args HandlerArgs) responseMessage { + response := responseMessage{} -// validates a password against the password-rules -func validatePassword(password string) bool { - return len(password) >= 12 && len(password) <= 64 + // check admin + if !args.User.Admin { + response.Status = fiber.StatusForbidden + } else { + // parse the body + var body users.UserAdd + + if err := args.C.BodyParser(&body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Warn().Msgf("can't parse body: %v", err) + + // validate the body + } else if err := validate.Struct(body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Warn().Msgf("invalid body: %v", err) + } else if err := users.Add(body); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Warn().Msgf("can't add user: %v", err) + } + } + + return response } diff --git a/client/src/Zustand.ts b/client/src/Zustand.ts index 755d98d..acb8f64 100644 --- a/client/src/Zustand.ts +++ b/client/src/Zustand.ts @@ -1,6 +1,5 @@ "use client"; -import { DateFormatter as IntlDateFormatter } from "@internationalized/date"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { apiCall } from "./lib"; @@ -18,16 +17,18 @@ export interface EventData { description: string; } +export interface User { + userName: string; + admin: boolean; +} + interface Zustand { events: EventData[]; pendingEvents: number; - user: { - userName: string; - admin: boolean; - } | null; + user: User | null; setEvents: (events: EventData[]) => void; reset: (zustand?: Partial) => void; - setPendingEvents: (c: number) => void; + getPendingEvents: () => Promise; } const initialState = { @@ -46,7 +47,18 @@ const zustand = create()( ...initialState, ...newZustand, }), - setPendingEvents: (c) => set(() => ({ pendingEvents: c })), + getPendingEvents: async () => { + const result = await apiCall<{ pendingEvents: number }>( + "GET", + "events/user/pending", + ); + + if (result.ok) { + const resultData = await result.json(); + + set(() => ({ pendingEvents: resultData })); + } + }, }), { name: "golunteer-storage", @@ -75,16 +87,4 @@ export async function getTasks(): Promise< } } -export class DateFormatter { - private formatter; - - constructor(locale: string, options?: Intl.DateTimeFormatOptions) { - this.formatter = new IntlDateFormatter(locale, options); - } - - format(dt: Date) { - return this.formatter.format(dt); - } -} - export default zustand; diff --git a/client/src/app/Header.tsx b/client/src/app/Header.tsx index 2585619..f4baf2f 100644 --- a/client/src/app/Header.tsx +++ b/client/src/app/Header.tsx @@ -14,13 +14,14 @@ import { Navbar, NavbarBrand, NavbarContent, - NavbarItem, NavbarMenu, NavbarMenuToggle, + Tab, + Tabs, } from "@nextui-org/react"; import zustand from "@/Zustand"; import { SiteLink } from "./layout"; -import React, { useEffect } from "react"; +import React from "react"; export default function Header({ sites }: { sites: SiteLink[] }) { const router = useRouter(); @@ -68,21 +69,6 @@ export default function Header({ sites }: { sites: SiteLink[] }) { } } - // get the pending events for the counter - useEffect(() => { - (async () => { - const result = await apiCall<{ pendingEvents: number }>( - "GET", - "events/user/pending", - ); - - if (result.ok) { - const resultJson = await result.json(); - zustand.getState().setPendingEvents(resultJson); - } - })(); - }, []); - return (
@@ -110,19 +96,13 @@ export default function Header({ sites }: { sites: SiteLink[] }) { - {sites.map((s) => - // if the site is no admin-site or the user is an admin, render it - !s.admin || user.admin ? ( - - - {s.text} - - - ) : null, - )} + + {sites.map((s) => + !s.admin || user.admin ? ( + + ) : null, + )} + diff --git a/client/src/app/Main.tsx b/client/src/app/Main.tsx index 506fe72..5c3c354 100644 --- a/client/src/app/Main.tsx +++ b/client/src/app/Main.tsx @@ -34,6 +34,8 @@ export default function Main({ children }: { children: React.ReactNode }) { const response = await welcomeResult.json(); if (response.userName !== undefined && response.userName !== "") { + void zustand.getState().getPendingEvents(); + zustand.getState().reset({ user: response }); loggedIn = true; diff --git a/client/src/app/admin/AddUser.tsx b/client/src/app/admin/AddUser.tsx new file mode 100644 index 0000000..2ca4f99 --- /dev/null +++ b/client/src/app/admin/AddUser.tsx @@ -0,0 +1,80 @@ +import { AddLarge, Copy } from "@carbon/icons-react"; +import { + Button, + Checkbox, + Form, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@nextui-org/react"; +import { FormEvent, useState } from "react"; + +export default function AddUser(props: { + isOpen: boolean; + onOpenChange?: (isOpen: boolean) => void; + onSubmit?: (e: FormEvent) => void; +}) { + const [password, setPassword] = useState(""); + + return ( + + + +

Add User

+
+ +
{ + e.preventDefault(); + props.onSubmit?.(e); + }} + > + + + navigator.clipboard.writeText(password)} + > + + + } + value={password} + onValueChange={setPassword} + /> + + Admin + + + + + +
+
+
+ ); +} diff --git a/client/src/app/admin/Users.tsx b/client/src/app/admin/Users.tsx new file mode 100644 index 0000000..f79bd01 --- /dev/null +++ b/client/src/app/admin/Users.tsx @@ -0,0 +1,113 @@ +import { apiCall } from "@/lib"; +import { User } from "@/Zustand"; +import { + Button, + Checkbox, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from "@nextui-org/react"; +import { useAsyncList } from "@react-stately/data"; +import { FormEvent, useState } from "react"; +import AddUser from "./AddUser"; + +export default function Users() { + const [showAddUser, setShowAddUser] = useState(false); + const users = useAsyncList({ + async load() { + return { + items: [ + { userName: "admin", admin: true }, + { userName: "foo", admin: false }, + { userName: "bar", admin: true }, + ], + }; + }, + async sort({ items, sortDescriptor }) { + return { + items: items.sort((a, b) => { + let cmp = 0; + + switch (sortDescriptor.column) { + case "admin": + if (a.admin && !b.admin) { + cmp = -1; + } else if (!a.admin && b.admin) { + cmp = 1; + } + break; + case "userName": + cmp = a.userName.localeCompare(b.userName); + } + + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + + return cmp; + }), + }; + }, + }); + + // send an addUser request to the backend then reload the table + async function addUser(e: FormEvent) { + const data = Object.fromEntries(new FormData(e.currentTarget)); + + const result = await apiCall("POST", "users", undefined, { + ...data, + admin: data.admin === "admin", + }); + + if (result.ok) { + users.reload(); + } + } + + const topContent = ( + <> + + + ); + + return ( +
+ + + + Username + + + Admin + + + + {(user) => ( + + {user.userName} + + + + + )} + +
+ + void addUser(e)} + /> +
+ ); +} diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx index 17953fe..bb325fc 100644 --- a/client/src/app/admin/page.tsx +++ b/client/src/app/admin/page.tsx @@ -1,280 +1,18 @@ "use client"; -import AddEvent from "@/components/Event/AddEvent"; -import LocalDate from "@/components/LocalDate"; -import { apiCall } from "@/lib"; -import { Availability, EventData, getTasks, Task } from "@/Zustand"; -import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react"; -import { - Button, - ButtonGroup, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Select, - SelectItem, - Table, - TableBody, - TableCell, - TableColumn, - TableHeader, - TableRow, - Tooltip, -} from "@nextui-org/react"; -import { useAsyncList } from "@react-stately/data"; -import React, { Key, useState } from "react"; - -type EventWithAvailabilities = EventData & { availabilities: string[] }; - -function availability2Tailwind(availability?: Availability) { - switch (availability) { - case "yes": - return ""; - default: - return "italic"; - } -} - -function availability2Color(availability?: Availability) { - switch (availability) { - case "yes": - return "default"; - case "maybe": - return "warning"; - default: - return "danger"; - } -} - -export default function AdminPanel() { - // get the available tasks and craft them into the headers - const headers = useAsyncList({ - async load() { - const tasks = await getTasks(); - - return { - items: [ - { key: "date", label: "Date" }, - { key: "description", label: "Description" }, - ...Object.entries(tasks) - .filter(([, task]) => !task.disabled) - .map(([id, task]) => ({ label: task.text, key: id })), - { key: "actions", label: "Action" }, - ], - }; - }, - }); - - // get the individual events - const events = useAsyncList({ - async load() { - const result = await apiCall( - "GET", - "events/availabilities", - ); - - if (result.ok) { - return { items: await result.json() }; - } else { - return { items: [] }; - } - }, - async sort({ items, sortDescriptor }) { - return { - items: items.sort((a, b) => { - let cmp = 0; - - // if it is the date-column, convert to a date - if (sortDescriptor.column === "date") { - const first = a[sortDescriptor.column]; - const second = b[sortDescriptor.column]; - - cmp = first < second ? -1 : 1; - } - - if (sortDescriptor.direction === "descending") { - cmp *= -1; - } - - return cmp; - }), - }; - }, - }); - - function getKeyValue( - event: EventWithAvailabilities, - key: Key, - ): React.ReactNode { - switch (key) { - case "date": - return ( - - {event[key]} - - ); - case "description": - return {event[key]}; - case "actions": - return ( -
- - - - - -
- ); - default: - return ( - - ); - } - } - - const [showAddEvent, setShowAddEvent] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [activeEvent, setActiveEvent] = useState(); - - const topContent = ( -
- -
- ); +import { Tab, Tabs } from "@nextui-org/react"; +import Users from "./Users"; +export default function AdminDashboard() { return ( -
-

Event Managment

- - tr]:first:!shadow-border", - }} - className="w-fit" - > - - {(task) => ( - - {task.label} - - )} - - - {(event) => ( - - {(columnKey) => ( - {getKeyValue(event, columnKey)} - )} - - )} - -
- - - - {activeEvent !== undefined ? ( - - - -

Confirm event deletion

-
- - The event{" "} - - - {activeEvent.date} - - {" "} - will be deleted. - - - - - -
-
- ) : null} +
+ + + + + Tasks + Availabilities +
); } diff --git a/client/src/app/assignments/page.tsx b/client/src/app/assignments/page.tsx new file mode 100644 index 0000000..944bc01 --- /dev/null +++ b/client/src/app/assignments/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import AddEvent from "@/components/Event/AddEvent"; +import LocalDate from "@/components/LocalDate"; +import { apiCall } from "@/lib"; +import { Availability, EventData, getTasks, Task } from "@/Zustand"; +import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react"; +import { + Button, + ButtonGroup, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Select, + SelectItem, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip, +} from "@nextui-org/react"; +import { useAsyncList } from "@react-stately/data"; +import React, { Key, useState } from "react"; + +type EventWithAvailabilities = EventData & { availabilities: string[] }; + +function availability2Tailwind(availability?: Availability) { + switch (availability) { + case "yes": + return ""; + default: + return "italic"; + } +} + +function availability2Color(availability?: Availability) { + switch (availability) { + case "yes": + return "default"; + case "maybe": + return "warning"; + default: + return "danger"; + } +} + +export default function AdminPanel() { + // get the available tasks and craft them into the headers + const headers = useAsyncList({ + async load() { + const tasks = await getTasks(); + + return { + items: [ + { key: "date", label: "Date" }, + { key: "description", label: "Description" }, + ...Object.values(tasks) + .filter((task) => !task.disabled) + .map((task) => ({ label: task.text, key: task.text })), + { key: "actions", label: "Action" }, + ], + }; + }, + }); + + // get the individual events + const events = useAsyncList({ + async load() { + const result = await apiCall( + "GET", + "events/availabilities", + ); + + if (result.ok) { + return { items: await result.json() }; + } else { + return { items: [] }; + } + }, + async sort({ items, sortDescriptor }) { + return { + items: items.sort((a, b) => { + let cmp = 0; + + // if it is the date-column, convert to a date + if (sortDescriptor.column === "date") { + const first = a[sortDescriptor.column]; + const second = b[sortDescriptor.column]; + + cmp = first < second ? -1 : 1; + } + + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + + return cmp; + }), + }; + }, + }); + + // send a delete request to the backend and close the popup on success + async function deleteEvent(eventId: number) { + const result = await apiCall("DELETE", "event", { id: eventId }); + + if (result.ok) { + // store the received events + events.reload(); + + setShowDeleteConfirm(false); + } + } + + function getKeyValue( + event: EventWithAvailabilities, + key: Key, + ): React.ReactNode { + switch (key) { + case "date": + return ( + + {event[key]} + + ); + case "description": + return {event[key]}; + case "actions": + return ( +
+ + + + + +
+ ); + default: + // only show the selector, if the task is needed for the event + if (Object.keys(event.tasks).includes(key as string)) { + return ( + + ); + } + } + } + + const [showAddEvent, setShowAddEvent] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [activeEvent, setActiveEvent] = useState(); + + const topContent = ( +
+ +
+ ); + + return ( +
+

Event Managment

+ + tr]:first:!shadow-border", + }} + className="w-fit" + > + + {(task) => ( + + {task.label} + + )} + + + {(event) => ( + + {(columnKey) => ( + {getKeyValue(event, columnKey)} + )} + + )} + +
+ + + + {activeEvent !== undefined ? ( + + + +

Confirm event deletion

+
+ + The event{" "} + + + {activeEvent.date} + + {" "} + will be deleted. + + + + + +
+
+ ) : null} +
+ ); +} diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index c51013b..964a9ba 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -34,6 +34,7 @@ export default function RootLayout({ { text: "Assignments", href: "/assignments", + admin: true, }, { text: "Admin", diff --git a/client/src/app/login/page.tsx b/client/src/app/login/page.tsx index dacc687..375978e 100644 --- a/client/src/app/login/page.tsx +++ b/client/src/app/login/page.tsx @@ -27,6 +27,9 @@ export default function Login() { // add the user-info to the zustand zustand.getState().reset({ user: await result.json() }); + // retrieve the notifications + await zustand.getState().getPendingEvents(); + // redirect to the home-page router.push("/"); } else { diff --git a/client/src/components/LocalDate.tsx b/client/src/components/LocalDate.tsx index 50100e2..3c8f60c 100644 --- a/client/src/components/LocalDate.tsx +++ b/client/src/components/LocalDate.tsx @@ -1,6 +1,6 @@ "use local"; -import { DateFormatter } from "@/Zustand"; +import { DateFormatter } from "@/lib"; import { getLocalTimeZone, parseDateTime } from "@internationalized/date"; import { useLocale } from "@react-aria/i18n"; diff --git a/client/src/lib.ts b/client/src/lib.ts index a35e260..54f2ab7 100644 --- a/client/src/lib.ts +++ b/client/src/lib.ts @@ -1,3 +1,5 @@ +import { DateFormatter as IntlDateFormatter } from "@internationalized/date"; + type QueryParams = Record; export type APICallResult = Response & { @@ -66,3 +68,15 @@ export function classNames(classNames: Record): string { }) .join(" "); } + +export class DateFormatter { + private formatter; + + constructor(locale: string, options?: Intl.DateTimeFormatOptions) { + this.formatter = new IntlDateFormatter(locale, options); + } + + format(dt: Date) { + return this.formatter.format(dt); + } +}