From 0685283007c597a9ad0310422d6d8044f1b2e7e5 Mon Sep 17 00:00:00 2001 From: z1glr Date: Sun, 12 Jan 2025 02:01:14 +0000 Subject: [PATCH] functionally completed user editing --- .../db/availabilities/userAvailabilities.go | 5 +- backend/pkg/db/users/users.go | 69 ++++----- backend/pkg/router/login.go | 28 ++-- backend/pkg/router/router.go | 46 +++--- backend/pkg/router/user.go | 119 ++++++++++++++- client/next.config.ts | 2 +- client/src/Zustand.ts | 22 +-- client/src/app/Header.tsx | 17 ++- client/src/app/Main.tsx | 6 +- client/src/app/Overview.tsx | 11 +- client/src/app/admin/AddUser.tsx | 1 - client/src/app/admin/EditUser.tsx | 137 ++++++++++++++++++ client/src/app/admin/Users.tsx | 50 ++++++- client/src/app/events/page.tsx | 2 - client/src/app/login/page.tsx | 3 - client/src/components/Event/AddEvent.tsx | 3 + client/src/lib.ts | 6 +- client/tailwind.config.ts | 15 +- 18 files changed, 410 insertions(+), 132 deletions(-) create mode 100644 client/src/app/admin/EditUser.tsx diff --git a/backend/pkg/db/availabilities/userAvailabilities.go b/backend/pkg/db/availabilities/userAvailabilities.go index 31abf0b..1f9e3d5 100644 --- a/backend/pkg/db/availabilities/userAvailabilities.go +++ b/backend/pkg/db/availabilities/userAvailabilities.go @@ -2,7 +2,6 @@ package availabilities import ( "github.com/johannesbuehl/golunteer/backend/pkg/db" - "github.com/johannesbuehl/golunteer/backend/pkg/db/users" ) type eventAvailabilities struct { @@ -23,11 +22,9 @@ func Event(eventID int) (map[string]string, error) { // get the availabilities if availabilitiesMap, err := Keys(); err != nil { return nil, err - } else if usersMap, err := users.Get(); err != nil { - return nil, err } else { for _, a := range availabilitiesRows { - eventAvailabilities[usersMap[a.UserName].Name] = availabilitiesMap[a.AvailabilityID].Text + eventAvailabilities[a.UserName] = availabilitiesMap[a.AvailabilityID].Text } return eventAvailabilities, nil diff --git a/backend/pkg/db/users/users.go b/backend/pkg/db/users/users.go index 1975c18..32a55b1 100644 --- a/backend/pkg/db/users/users.go +++ b/backend/pkg/db/users/users.go @@ -1,42 +1,45 @@ package users 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 { - Name string `db:"name"` - Password []byte `db:"password"` - TokenID string `db:"tokenID"` - Admin bool `db:"admin"` + Name string `db:"name" json:"userName"` + Admin bool `db:"admin" json:"admin"` } -var c *cache.Cache - // hashes a password func hashPassword(password string) ([]byte, error) { return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) } -func Get() (map[string]User, error) { - if users, hit := c.Get("users"); !hit { - refresh() +func Get() ([]User, error) { + // get the users from the database + var users []User - return nil, fmt.Errorf("users not cached") + if err := db.DB.Select(&users, "SELECT name, admin FROM USERS"); err != nil { + return nil, err } else { - return users.(map[string]User), nil + return users, nil } } +func TokenID(userName string) (string, error) { + var dbResult struct { + TokenID string `db:"tokenID"` + } + + err := db.DB.Get(&dbResult, "SELECT tokenID FROM USERS WHERE name = ?", userName) + + return dbResult.TokenID, err +} + type UserAdd struct { UserName string `json:"userName" validate:"required" db:"userName"` - Password string `json:"password" validate:"required,min=12"` + Password string `json:"password" validate:"required,min=12,max=64"` Admin bool `json:"admin" db:"admin"` } @@ -55,13 +58,9 @@ func Add(user UserAdd) error { 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() + _, err := db.DB.NamedExec("INSERT INTO USERS (name, password, admin, tokenID) VALUES (:userName, :password, :admin, :tokenID)", insertUser) - return nil - } + return err } } @@ -88,33 +87,19 @@ func ChangePassword(user UserChangePassword) (string, error) { if _, err := db.DB.NamedExec("UPDATE USERS SET tokenID = :tokenID, password = :password WHERE name = :userName", execStruct); err != nil { return "", err } else { - refresh() - return execStruct.TokenID, nil } } } -func refresh() { - // get the usersRaw from the database - var usersRaw []User +func ChangeName(userName, newName string) error { + _, err := db.DB.Exec("UPDATE USERS SET name = ? WHERE name = ?", newName, userName) - if err := db.DB.Select(&usersRaw, "SELECT * FROM USERS"); err == nil { - // convert the result in a map - users := map[string]User{} - - for _, user := range usersRaw { - users[user.Name] = user - } - - c.Set("users", users) - } + return err } -func init() { - c = cache.New(24 * time.Hour) +func SetAdmin(userName string, admin bool) error { + _, err := db.DB.Exec("UPDATE USERS SET admin = ? WHERE name = ?", admin, userName) - c.OnExpired(refresh) - - refresh() + return err } diff --git a/backend/pkg/router/login.go b/backend/pkg/router/login.go index ccadb1a..02c0c0b 100644 --- a/backend/pkg/router/login.go +++ b/backend/pkg/router/login.go @@ -15,21 +15,23 @@ func handleWelcome(c *fiber.Ctx) error { Admin: false, } - if user, err := checkUser(c); err != nil { + args := HandlerArgs{C: c} + + if loggedIn, err := args.checkUser(); err != nil { response.Status = fiber.StatusInternalServerError logger.Warn().Msgf("can't check user: %v", err) - } else if user == nil { - response.Status = fiber.StatusNoContent + } else if !loggedIn { + response.Status = fiber.StatusUnauthorized logger.Debug().Msgf("user not authorized") } else { response.Data = UserChecked{ - UserName: user.UserName, - Admin: user.Admin, + UserName: args.User.UserName, + Admin: args.User.Admin, } - logger.Debug().Msgf("welcomed user %q", user.UserName) + logger.Debug().Msgf("welcomed user %q", args.User.UserName) } return response.send(c) @@ -40,6 +42,8 @@ const messageWrongLogin = "Unkown user or wrong password" func handleLogin(c *fiber.Ctx) error { logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) + args := HandlerArgs{C: c} + // extract username and password from the request requestBody := struct { Username string `json:"userName" validate:"required"` @@ -48,7 +52,7 @@ func handleLogin(c *fiber.Ctx) error { var response responseMessage - if err := c.BodyParser(&requestBody); err != nil { + if err := args.C.BodyParser(&requestBody); err != nil { logger.Debug().Msgf("can't parse login-body: %v", err) response.Status = fiber.StatusBadRequest @@ -79,7 +83,7 @@ func handleLogin(c *fiber.Ctx) error { response.Status = fiber.StatusInternalServerError logger.Error().Msgf("can't create JWT: %v", err) } else { - setSessionCookie(c, &jwt) + args.setSessionCookie(&jwt) response.Data = UserChecked{ UserName: requestBody.Username, @@ -92,14 +96,18 @@ func handleLogin(c *fiber.Ctx) error { } } - return response.send(c) + return response.send(args.C) } // handles logout-requests func handleLogout(c *fiber.Ctx) error { logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) - removeSessionCookie(c) + args := HandlerArgs{ + C: c, + } + + args.removeSessionCookie() return responseMessage{}.send(c) } diff --git a/backend/pkg/router/router.go b/backend/pkg/router/router.go index 4f52ec8..6fc5094 100644 --- a/backend/pkg/router/router.go +++ b/backend/pkg/router/router.go @@ -79,6 +79,7 @@ func init() { "events/availabilities": getEventsAvailabilities, "events/user/pending": getEventsUserPending, "tasks": getTasks, + "users": getUsers, }, "POST": { "events": postEvent, @@ -86,6 +87,7 @@ func init() { }, "PATCH": { "users/password": patchPassword, + "users": patchUser, }, "DELETE": { "event": deleteEvent, @@ -104,24 +106,24 @@ func init() { logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) var response responseMessage + args := HandlerArgs{ + C: c, + } - if user, err := checkUser(c); err != nil { + if loggedIn, err := args.checkUser(); err != nil { response = responseMessage{ Status: fiber.StatusBadRequest, } logger.Error().Msgf("can't check user: %v", err) - } else if user == nil { + } else if !loggedIn { response = responseMessage{ - Status: fiber.StatusNoContent, + Status: fiber.StatusUnauthorized, } logger.Log().Msgf("user not authorized") } else { - response = handler(HandlerArgs{ - C: c, - User: *user, - }) + response = handler(args) } return response.send(c) @@ -137,16 +139,16 @@ func Listen() { fmt.Println(err) } -func setSessionCookie(c *fiber.Ctx, jwt *string) { +func (args HandlerArgs) setSessionCookie(jwt *string) { var value string if jwt == nil { - value = c.Cookies("session") + value = args.C.Cookies("session") } else { value = *jwt } - c.Cookie(&fiber.Cookie{ + args.C.Cookie(&fiber.Cookie{ Name: "session", Value: value, HTTPOnly: true, @@ -156,8 +158,8 @@ func setSessionCookie(c *fiber.Ctx, jwt *string) { } // removes the session-coockie from a request -func removeSessionCookie(c *fiber.Ctx) { - c.Cookie(&fiber.Cookie{ +func (args HandlerArgs) removeSessionCookie() { + args.C.Cookie(&fiber.Cookie{ Name: "session", Value: "", HTTPOnly: true, @@ -219,11 +221,11 @@ type UserChecked struct { } // checks wether the request is from a valid user -func checkUser(c *fiber.Ctx) (*UserChecked, error) { - userName, tokenID, err := extractJWT(c) +func (args *HandlerArgs) checkUser() (bool, error) { + userName, tokenID, err := extractJWT(args.C) if err != nil { - return nil, nil + return false, nil } var dbResult struct { @@ -232,19 +234,21 @@ func checkUser(c *fiber.Ctx) (*UserChecked, error) { } // retrieve the user from the database - if err := db.DB.QueryRowx("SELECT tokenID, admin FROM USERS WHERE name = ?", userName).StructScan(&dbResult); err != nil { - return nil, err + if err := db.DB.Get(&dbResult, "SELECT tokenID, admin FROM USERS WHERE name = ?", userName); err != nil { + return false, err // if the tokenID is valid, the user is authorized } else if dbResult.TokenID != tokenID { - return nil, err + return false, nil } else { // reset the expiration of the cookie - setSessionCookie(c, nil) + args.setSessionCookie(nil) - return &UserChecked{ + args.User = UserChecked{ UserName: userName, Admin: dbResult.Admin, - }, err + } + return true, nil + } } diff --git a/backend/pkg/router/user.go b/backend/pkg/router/user.go index ac2a530..2dee62d 100644 --- a/backend/pkg/router/user.go +++ b/backend/pkg/router/user.go @@ -5,6 +5,25 @@ import ( "github.com/johannesbuehl/golunteer/backend/pkg/db/users" ) +func getUsers(args HandlerArgs) responseMessage { + response := responseMessage{} + + // check admin + if !args.User.Admin { + response.Status = fiber.StatusForbidden + + logger.Log().Msgf("user is no admin") + } else if users, err := users.Get(); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't get users: %v", err) + } else { + response.Data = users + } + + return response +} + func postUser(args HandlerArgs) responseMessage { response := responseMessage{} @@ -63,12 +82,108 @@ func patchPassword(args HandlerArgs) responseMessage { // if something failed, remove the current session-cookie }); err != nil { - removeSessionCookie(args.C) + args.removeSessionCookie() // set the new session-cookie } else { // update the token in the session-cookie - setSessionCookie(args.C, &jwt) + args.setSessionCookie(&jwt) + } + } + + return response +} + +func patchUser(args HandlerArgs) responseMessage { + response := responseMessage{} + // check admin + if !args.User.Admin { + response.Status = fiber.StatusForbidden + + logger.Log().Msgf("user is no admin") + } else { + // parse the body + var body struct { + users.UserAdd + NewName string `json:"newName"` + } + + if err := args.C.BodyParser(&body); err != nil { + response.Status = fiber.StatusBadRequest + + logger.Log().Msgf("can't parse body: %v", err) + + // prevent to demoting self from admin + } else if !body.Admin && body.UserName == args.User.UserName { + response.Status = fiber.StatusBadRequest + + logger.Warn().Msgf("can't demote self from admin") + } else { + // check for an empty user-name + if len(body.UserName) == 0 { + response.Status = fiber.StatusBadRequest + + logger.Warn().Msgf("username is empty") + + // if the password has length 0 assume the password shouldn't be changed + } else { + if len(body.Password) > 0 { + // create a password-change-struct and validate it. use the old user-name, since the new isn't stored yet + usePasswordChange := users.UserChangePassword{ + UserName: body.UserName, + Password: body.Password, + } + + if _, err = users.ChangePassword(usePasswordChange); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't change password: %v", err) + + return response + } + } + + // only change the name, if it differs + if body.NewName != body.UserName { + if err := users.ChangeName(body.UserName, body.NewName); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't change user-name: %v", err) + + return response + } + } + + // set the admin-status + if err := users.SetAdmin(body.NewName, body.Admin); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("updating admin-status failed: %v", err) + } else { + // if we modified ourself, update the session-cookie + if body.UserName == args.User.UserName { + // get the tokenID + if tokenID, err := users.TokenID(body.NewName); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("can't get tokenID: %v", err) + + } else if jwt, err := config.SignJWT(JWTPayload{ + UserName: body.NewName, + TokenID: tokenID, + }); err != nil { + response.Status = fiber.StatusInternalServerError + + logger.Error().Msgf("JWT-signing failed: %v", err) + + // remove the session-cookie + args.removeSessionCookie() + } else { + args.setSessionCookie(&jwt) + } + } + } + } } } diff --git a/client/next.config.ts b/client/next.config.ts index 1942dec..46820df 100644 --- a/client/next.config.ts +++ b/client/next.config.ts @@ -10,7 +10,7 @@ const nextConfig: NextConfig = { fallback: [ { source: "/api/:path*", - destination: "http://golunteer-frontend:8080/api/:path*", + destination: "http://golunteer-backend:8080/api/:path*", }, ], }), diff --git a/client/src/Zustand.ts b/client/src/Zustand.ts index 65100bf..32aafbd 100644 --- a/client/src/Zustand.ts +++ b/client/src/Zustand.ts @@ -2,7 +2,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { apiCall } from "./lib"; export interface EventData { id: number; @@ -17,48 +16,29 @@ export interface User { } interface Zustand { - events: EventData[]; - pendingEvents: number; user: User | null; - setEvents: (events: EventData[]) => void; reset: (zustand?: Partial) => void; - getPendingEvents: () => Promise; } const initialState = { - events: [], user: null, - pendingEvents: 0, }; const zustand = create()( persist( (set) => ({ ...initialState, - setEvents: (events) => set({ events }), reset: (newZustand) => set({ ...initialState, ...newZustand, }), - 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", partialize: (state) => Object.fromEntries( - Object.entries(state).filter(([key]) => !["events"].includes(key)), + Object.entries(state).filter(([key]) => ["user"].includes(key)), ), }, ), diff --git a/client/src/app/Header.tsx b/client/src/app/Header.tsx index f4baf2f..988d46c 100644 --- a/client/src/app/Header.tsx +++ b/client/src/app/Header.tsx @@ -21,12 +21,25 @@ import { } from "@nextui-org/react"; import zustand from "@/Zustand"; import { SiteLink } from "./layout"; -import React from "react"; +import React, { useEffect, useState } from "react"; export default function Header({ sites }: { sites: SiteLink[] }) { const router = useRouter(); const user = zustand((state) => state.user); - const pendingEvents = zustand((state) => state.pendingEvents); + const [pendingEvents, setPendingEvents] = useState(0); + + useEffect(() => { + (async () => { + const result = await apiCall<{ pendingEvents: number }>( + "GET", + "events/user/pending", + ); + + if (result.ok) { + setPendingEvents(await result.json()); + } + })(); + }, []); const pathname = usePathname(); diff --git a/client/src/app/Main.tsx b/client/src/app/Main.tsx index 5c3c354..ad946a8 100644 --- a/client/src/app/Main.tsx +++ b/client/src/app/Main.tsx @@ -18,6 +18,7 @@ export default function Main({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); + const user = zustand((state) => state.user); useEffect(() => { void (async () => { @@ -34,14 +35,13 @@ 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; } } catch {} } else { + zustand.getState().reset(); } } else { loggedIn = true; @@ -62,7 +62,7 @@ export default function Main({ children }: { children: React.ReactNode }) { } } })(); - }, [pathname, router]); + }, [pathname, router, user]); switch (auth) { case AuthState.Loading: diff --git a/client/src/app/Overview.tsx b/client/src/app/Overview.tsx index 5ddf570..c6f036a 100644 --- a/client/src/app/Overview.tsx +++ b/client/src/app/Overview.tsx @@ -1,11 +1,8 @@ "use client"; import { Add } from "@carbon/icons-react"; -import Event from "../components/Event/Event"; import { useState } from "react"; import AddEvent from "../components/Event/AddEvent"; -import zustand from "../Zustand"; -import AssignmentTable from "@/components/Event/AssignmentTable"; import { Button } from "@nextui-org/react"; export default function EventVolunteer() { @@ -14,13 +11,7 @@ export default function EventVolunteer() { return (

Overview

-
- {zustand.getState().events.map((ee) => ( - - - - ))} -
+
+ + + + ) : null} + + ); +} diff --git a/client/src/app/admin/Users.tsx b/client/src/app/admin/Users.tsx index f79bd01..de6938b 100644 --- a/client/src/app/admin/Users.tsx +++ b/client/src/app/admin/Users.tsx @@ -9,22 +9,33 @@ import { TableColumn, TableHeader, TableRow, + Tooltip, } from "@nextui-org/react"; import { useAsyncList } from "@react-stately/data"; import { FormEvent, useState } from "react"; import AddUser from "./AddUser"; +import { Edit } from "@carbon/icons-react"; +import EditUser from "./EditUser"; export default function Users() { const [showAddUser, setShowAddUser] = useState(false); + const [editUser, setEditUser] = useState(); + const users = useAsyncList({ async load() { - return { - items: [ - { userName: "admin", admin: true }, - { userName: "foo", admin: false }, - { userName: "bar", admin: true }, - ], - }; + const result = await apiCall("GET", "users"); + + if (result.ok) { + const users = (await result.json()) as User[]; + + return { + items: users, + }; + } else { + return { + items: [], + }; + } }, async sort({ items, sortDescriptor }) { return { @@ -67,6 +78,7 @@ export default function Users() { } } + // content above the user-tabel const topContent = ( <> @@ -90,6 +102,7 @@ export default function Users() { Admin + Edit {(user) => ( @@ -98,6 +111,18 @@ export default function Users() { + + + )} @@ -108,6 +133,17 @@ export default function Users() { onOpenChange={setShowAddUser} onSubmit={(e) => void addUser(e)} /> + + !isOpen ? setEditUser(undefined) : undefined + } + onSuccess={() => { + users.reload(); + setEditUser(undefined); + }} + />
); } diff --git a/client/src/app/events/page.tsx b/client/src/app/events/page.tsx index d2a5247..8c7be10 100644 --- a/client/src/app/events/page.tsx +++ b/client/src/app/events/page.tsx @@ -15,8 +15,6 @@ export default function Events() { useEffect(() => { (async () => { - console.debug("query"); - const data = await apiCall("GET", "events/assignments"); if (data.ok) { diff --git a/client/src/app/login/page.tsx b/client/src/app/login/page.tsx index 375978e..dacc687 100644 --- a/client/src/app/login/page.tsx +++ b/client/src/app/login/page.tsx @@ -27,9 +27,6 @@ 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/Event/AddEvent.tsx b/client/src/components/Event/AddEvent.tsx index 15e71a2..47a6276 100644 --- a/client/src/components/Event/AddEvent.tsx +++ b/client/src/components/Event/AddEvent.tsx @@ -33,6 +33,7 @@ export default function AddEvent(props: { className?: string; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + onSuccess?: () => void; }) { // initial state for the inputs const initialState: state = { @@ -73,6 +74,8 @@ export default function AddEvent(props: { zustand.getState().setEvents(await result.json()); props.onOpenChange(false); + + props.onSuccess?.(); } } diff --git a/client/src/lib.ts b/client/src/lib.ts index d5b9323..4fbb8d4 100644 --- a/client/src/lib.ts +++ b/client/src/lib.ts @@ -84,8 +84,10 @@ export class DateFormatter { export function vaidatePassword(password: string): string[] { const errors = []; - if (password.length < 1) { - errors.push("Password must be 16 characters or more"); + if (password.length < 12) { + errors.push("Password must be 12 characters or more"); + } else if (password.length > 64) { + errors.push("Password must be 64 characters or short"); } return errors; diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 11b1354..12ffb59 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -20,7 +20,20 @@ export default { theme: { extend: { colors: { - highlight: HIGHLIGHT, + highlight: { + DEFAULT: HIGHLIGHT, + "50": "hsl(0,85.7%,97.3%)", + "100": "hsl(0,93.3%,94.1%)", + "200": "hsl(0,96.3%,89.4%)", + "300": "hsl(0,93.5%,81.8%)", + "400": "hsl(0,90.6%,70.8%)", + "500": "hsl(0,84.2%,60.2%)", + "600": "hsl(0,72.2%,50.6%)", + "700": "hsl(0,73.7%,41.8%)", + "800": "hsl(0,70%,35.3%)", + "900": "hsl(0,62.8%,30.6%)", + "950": "hsl(0,74.7%,15.5%)", + }, foreground: FOREGROUND, "accent-1": ACCENT1, "accent-2": ACCENT2,