started "real work"

This commit is contained in:
z1glr
2025-01-10 14:06:53 +00:00
parent e2aa65b416
commit 45f600268f
30 changed files with 9811 additions and 8584 deletions

View File

@@ -6,10 +6,14 @@ require github.com/go-sql-driver/mysql v1.8.1
require ( require (
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jfarleyx/go-simple-cache v1.1.0 // indirect github.com/jfarleyx/go-simple-cache v1.1.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/compress v1.17.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
@@ -17,11 +21,14 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-playground/validator/v10 v10.23.0
github.com/gofiber/fiber/v2 v2.52.6 github.com/gofiber/fiber/v2 v2.52.6
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0

View File

@@ -3,6 +3,14 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -18,6 +26,8 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -42,11 +52,15 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=

View File

@@ -4,18 +4,18 @@ import (
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
) )
type assignments map[string]string type assignments map[string]*string
type assignemntDB struct { type assignemntDB struct {
TaskName string `db:"taskName"` TaskName string `db:"taskName"`
UserName string `db:"userName"` UserName *string `db:"userName"`
} }
func Event(eventID int) (assignments, error) { func Event(eventID int) (assignments, error) {
// get the assignments from the database // get the assignments from the database
var assignmentRows []assignemntDB var assignmentRows []assignemntDB
if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil { if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS LEFT JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil {
return nil, err return nil, err
} else { } else {
// transform the rows into the returned map // transform the rows into the returned map

View File

@@ -3,46 +3,70 @@ package events
import ( import (
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/assignments" "github.com/johannesbuehl/golunteer/backend/pkg/db/assignments"
"github.com/johannesbuehl/golunteer/backend/pkg/logger"
) )
type Event struct { type EventWithAssignment struct {
eventDataDB eventDataDB
Tasks []string Tasks map[string]*string `json:"tasks"`
Assignments map[string]string
} }
type eventDataDB struct { type eventDataDB struct {
Id int `db:"id"` Id int `db:"id" json:"id"`
Date string `db:"date"` Date string `db:"date" json:"date"`
Description string `db:"description"` Description string `db:"description" json:"description"`
} }
// transform the database-entry to an Event // transform the database-entry to an Event
func (e *eventDataDB) Event() (Event, error) { func (e *eventDataDB) Event() (EventWithAssignment, error) {
// get the availabilites associated with the event // get the availabilites associated with the event
if assignemnts, err := assignments.Event(e.Id); err != nil { if assignemnts, err := assignments.Event(e.Id); err != nil {
return Event{}, err return EventWithAssignment{}, err
} else { } else {
return Event{ return EventWithAssignment{
eventDataDB: *e, eventDataDB: *e,
Assignments: assignemnts, Tasks: assignemnts,
}, nil }, nil
} }
} }
// get all the event ids func All() ([]eventDataDB, error) {
func All() (map[int]eventDataDB, error) {
var dbRows []eventDataDB var dbRows []eventDataDB
if err := db.DB.Select(&dbRows, "SELECT * FROM EVENTS"); err != nil { if err := db.DB.Select(&dbRows, "SELECT *, DATE_FORMAT(date, '%Y-%m-%dT%H:%i:%s') as date FROM EVENTS"); err != nil {
return nil, err return nil, err
} else { } else {
eventsMap := map[int]eventDataDB{} return dbRows, nil
}
for _, idRow := range dbRows { }
eventsMap[idRow.Id] = idRow
} func WithAssignments() ([]EventWithAssignment, error) {
// get all events
return eventsMap, nil if eventsDB, err := All(); err != nil {
return nil, err
} else {
events := make([]EventWithAssignment, len(eventsDB))
for ii, e := range eventsDB {
if ev, err := e.Event(); err != nil {
logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.Id, err)
} else {
events[ii] = ev
}
}
return events, nil
}
}
func UserPending(userName string) (int, error) {
var result struct {
Count int `db:"count(*)"`
}
if err := db.DB.QueryRowx("SELECT count(*) FROM USERS WHERE name = ? AND name NOT IN (SELECT userName FROM USER_AVAILABILITIES)", userName).StructScan(&result); err != nil {
return 0, err
} else {
return result.Count, nil
} }
} }

View File

@@ -5,28 +5,29 @@ import (
"github.com/johannesbuehl/golunteer/backend/pkg/db/events" "github.com/johannesbuehl/golunteer/backend/pkg/db/events"
) )
func getEvents(c *fiber.Ctx) responseMessage { func getEventsAssignments(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}
// get all eventRows if events, err := events.WithAssignments(); err != nil {
if eventRows, err := events.All(); err != nil {
response.Status = fiber.StatusInternalServerError response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("events retrieving failed: %v", err) logger.Error().Msgf("can't retrieve events with assignments: %v", err)
} else { } else {
// get the data for all the allEvents response.Data = events
allEvents := []events.Event{} }
for _, eventRow := range eventRows { return response
if e, err := eventRow.Event(); err != nil { }
logger.Error().Msgf("error while populating event with id = %d: %v", eventRow.Id, err)
} else { func getEventsUserPending(args HandlerArgs) responseMessage {
allEvents = append(allEvents, e) response := responseMessage{}
}
if count, err := events.UserPending(args.User.UserName); err != nil {
// response.Data = struct{ Events []events.Event }{Events: allEvents} response.Status = fiber.StatusInternalServerError
response.Data = allEvents
} logger.Warn().Msgf("can't query database for users %q pending events: %v", args.User.UserName, err)
} else {
response.Data = count
} }
return response return response

View File

@@ -1,67 +1,98 @@
package router package router
import ( import (
"strconv"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
"golang.org/x/crypto/bcrypt"
) )
type UserLogin struct {
UserName string `json:"userName"`
LoggedIn bool `json:"loggedIn"`
}
// handle welcome-messages from clients // handle welcome-messages from clients
func handleWelcome(c *fiber.Ctx) error { func handleWelcome(c *fiber.Ctx) error {
logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL())
response := responseMessage{} response := responseMessage{}
response.Data = UserLogin{ response.Data = UserChecked{
LoggedIn: false, Admin: false,
} }
if ok, err := checkUser(c); err != nil { if user, err := checkUser(c); err != nil {
response.Status = fiber.StatusInternalServerError response.Status = fiber.StatusInternalServerError
logger.Warn().Msgf("can't check user: %v", err) logger.Warn().Msgf("can't check user: %v", err)
} else if !ok { } else if user == nil {
response.Status = fiber.StatusNoContent response.Status = fiber.StatusNoContent
} else {
if uid, _, err := extractJWT(c); err != nil {
response.Status = fiber.StatusBadRequest
logger.Error().Msgf("can't extract JWT: %v", err) logger.Debug().Msgf("user not authorized")
} else { } else {
if users, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", strconv.Itoa(uid)); err != nil { response.Data = UserChecked{
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't get users from database: %v", err)
} else {
if len(users) != 1 {
response.Status = fiber.StatusForbidden
response.Message = "unknown user"
removeSessionCookie(c)
} else {
user := users[0]
response.Data = UserLogin{
UserName: user.UserName, UserName: user.UserName,
LoggedIn: true, Admin: user.Admin,
}
} }
logger.Debug().Msgf("welcomed user with uid = %v", uid) logger.Debug().Msgf("welcomed user %q", user.UserName)
}
}
} }
return response.send(c) return response.send(c)
} }
const messageWrongLogin = "Unkown user or wrong password"
func handleLogin(c *fiber.Ctx) error { func handleLogin(c *fiber.Ctx) error {
panic("not implemented yet") logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL())
// extract username and password from the request
requestBody := struct {
Username string `json:"userName" validate:"required"`
Password string `json:"password" validate:"required"`
}{}
var response responseMessage
if err := c.BodyParser(&requestBody); err != nil {
logger.Debug().Msgf("can't parse login-body: %v", err)
response.Status = fiber.StatusBadRequest
// validate the body
} else if err := validate.Struct(requestBody); err != nil {
logger.Warn().Msgf("can't parse login-body: %v", err)
} else {
// query the database for the user
var result userDB
if err := db.DB.QueryRowx("SELECT password, admin, tokenID FROM USERS WHERE name = ?", requestBody.Username).StructScan(&result); err != nil {
response.Status = fiber.StatusForbidden
response.Message = messageWrongLogin
logger.Info().Msgf("can't get user with name = %q from database", requestBody.Username)
} else {
// hash the password
if bcrypt.CompareHashAndPassword(result.Password, []byte(requestBody.Password)) != nil {
response.Status = fiber.StatusForbidden
logger.Info().Msgf("login denied: wrong password for user with name = %q", requestBody.Username)
} else {
// password is correct -> generate the JWT
if jwt, err := config.SignJWT(JWTPayload{
UserID: requestBody.Username,
TokenID: result.TokenID,
}); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't create JWT: %v", err)
} else {
setSessionCookie(c, &jwt)
response.Data = UserChecked{
UserName: requestBody.Username,
Admin: true,
}
logger.Debug().Msgf("user %q logged in", requestBody.Username)
}
}
}
}
return response.send(c)
} }
// handles logout-requests // handles logout-requests
@@ -70,9 +101,5 @@ func handleLogout(c *fiber.Ctx) error {
removeSessionCookie(c) removeSessionCookie(c)
return responseMessage{ return responseMessage{}.send(c)
Data: UserLogin{
LoggedIn: false,
},
}.send(c)
} }

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
_config "github.com/johannesbuehl/golunteer/backend/pkg/config" _config "github.com/johannesbuehl/golunteer/backend/pkg/config"
@@ -11,6 +12,8 @@ import (
_logger "github.com/johannesbuehl/golunteer/backend/pkg/logger" _logger "github.com/johannesbuehl/golunteer/backend/pkg/logger"
) )
var validate *validator.Validate
var logger = _logger.Logger var logger = _logger.Logger
var config = _config.Config var config = _config.Config
@@ -47,7 +50,14 @@ func (result responseMessage) send(c *fiber.Ctx) error {
} }
} }
type HandlerArgs struct {
C *fiber.Ctx
User UserChecked
}
func init() { func init() {
validate = validator.New()
// setup fiber // setup fiber
app = fiber.New(fiber.Config{ app = fiber.New(fiber.Config{
AppName: "johannes-pv", AppName: "johannes-pv",
@@ -63,8 +73,8 @@ func init() {
} }
// map with the individual registered endpoints // map with the individual registered endpoints
endpoints := map[string]map[string]func(*fiber.Ctx) responseMessage{ endpoints := map[string]map[string]func(HandlerArgs) responseMessage{
"GET": {"events": getEvents}, "GET": {"events/assignments": getEventsAssignments, "events/user/pending": getEventsUserPending},
"POST": {}, "POST": {},
"PATCH": {}, "PATCH": {},
"DELETE": {}, "DELETE": {},
@@ -81,7 +91,28 @@ func init() {
handleMethods[method]("/api/"+address, func(c *fiber.Ctx) error { handleMethods[method]("/api/"+address, func(c *fiber.Ctx) error {
logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL()) logger.Debug().Msgf("HTTP %s request: %q", c.Method(), c.OriginalURL())
return handler(c).send(c) var response responseMessage
if user, err := checkUser(c); err != nil {
response = responseMessage{
Status: fiber.StatusBadRequest,
}
logger.Error().Msgf("can't check user: %v", err)
} else if user == nil {
response = responseMessage{
Status: fiber.StatusNoContent,
}
logger.Log().Msgf("user not authorized")
} else {
response = handler(HandlerArgs{
C: c,
User: *user,
})
}
return response.send(c)
}) })
} }
} }
@@ -125,7 +156,7 @@ func removeSessionCookie(c *fiber.Ctx) {
// payload of the JSON webtoken // payload of the JSON webtoken
type JWTPayload struct { type JWTPayload struct {
UserID int `json:"userID"` UserID string `json:"userID"`
TokenID string `json:"tokenID"` TokenID string `json:"tokenID"`
} }
@@ -138,7 +169,7 @@ type JWT struct {
// extracts the json webtoken from the request // extracts the json webtoken from the request
// //
// @returns (userID, tokenID, error) // @returns (userID, tokenID, error)
func extractJWT(c *fiber.Ctx) (int, string, error) { func extractJWT(c *fiber.Ctx) (string, string, error) {
// get the session-cookie // get the session-cookie
cookie := c.Cookies("session") cookie := c.Cookies("session")
@@ -151,70 +182,57 @@ func extractJWT(c *fiber.Ctx) (int, string, error) {
}) })
if err != nil { if err != nil {
return -1, "", err return "", "", err
} }
// extract the claims from the JWT // extract the claims from the JWT
if claims, ok := token.Claims.(*JWT); ok && token.Valid { if claims, ok := token.Claims.(*JWT); ok && token.Valid {
return claims.CustomClaims.UserID, claims.CustomClaims.TokenID, nil return claims.CustomClaims.UserID, claims.CustomClaims.TokenID, nil
} else { } else {
return -1, "", fmt.Errorf("invalid JWT") return "", "", fmt.Errorf("invalid JWT")
} }
} }
// user-entry in the database // user-entry in the database
type UserDB struct { type userDB struct {
UserName string `json:"userName"` UserName string `db:"userName"`
Password []byte `json:"password"` Password []byte `db:"password"`
Admin bool `json:"admin"` Admin bool `db:"admin"`
TokenID string `json:"tokenID"` TokenID string `db:"tokenID"`
}
type UserChecked struct {
UserName string `json:"userName" db:"userName"`
Admin bool `json:"admin" db:"admin"`
} }
// checks wether the request is from a valid user // checks wether the request is from a valid user
func checkUser(c *fiber.Ctx) (bool, error) { func checkUser(c *fiber.Ctx) (*UserChecked, error) {
uid, tid, err := extractJWT(c) userName, tokenID, err := extractJWT(c)
if err != nil { if err != nil {
return false, nil return nil, nil
}
var dbResult struct {
TokenID string `db:"tokenID"`
Admin bool `db:"admin"`
} }
// retrieve the user from the database // retrieve the user from the database
response, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", uid) if err := db.DB.QueryRowx("SELECT tokenID, admin FROM USERS WHERE name = ?", userName).StructScan(&dbResult); err != nil {
return nil, err
if err != nil { // if the tokenID is valid, the user is authorized
return false, err } else if dbResult.TokenID != tokenID {
} return nil, err
} else {
// if exactly one user came back and the tID is valid, the user is authorized
if len(response) == 1 && response[0].TokenID == tid {
// reset the expiration of the cookie // reset the expiration of the cookie
setSessionCookie(c, nil) setSessionCookie(c, nil)
return true, err return &UserChecked{
} else { UserName: userName,
return false, err Admin: dbResult.Admin,
} }, err
}
// checks wether the request is from the admin
func checkAdmin(c *fiber.Ctx) (bool, error) {
uid, tokenID, err := extractJWT(c)
if err != nil {
return false, err
}
// retrieve the user from the database
response, err := db.SelectOld[UserDB]("users", "uid = ? LIMIT 1", uid)
if err != nil {
return false, err
}
// if exactly one user came back and its name is "admin", the user is the admin
if len(response) != 1 {
return false, fmt.Errorf("user doesn't exist")
} else {
return response[0].UserName == "admin" && response[0].TokenID == tokenID, err
} }
} }

View File

@@ -2,7 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
// output: "export", output: "export",
rewrites: async () => ({ rewrites: async () => ({
beforeFiles: [], beforeFiles: [],
afterFiles: [], afterFiles: [],

1085
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,23 +11,9 @@
"dependencies": { "dependencies": {
"@carbon/icons-react": "^11.53.0", "@carbon/icons-react": "^11.53.0",
"@internationalized/date": "^3.6.0", "@internationalized/date": "^3.6.0",
"@nextui-org/button": "^2.2.9", "@nextui-org/react": "^2.6.11",
"@nextui-org/card": "^2.2.9",
"@nextui-org/checkbox": "^2.3.8",
"@nextui-org/date-picker": "^2.3.9",
"@nextui-org/divider": "^2.2.5",
"@nextui-org/form": "^2.1.8",
"@nextui-org/input": "^2.4.8",
"@nextui-org/link": "^2.2.7",
"@nextui-org/modal": "^2.2.7",
"@nextui-org/radio": "^2.3.8",
"@nextui-org/select": "^2.4.9",
"@nextui-org/spinner": "^2.2.6",
"@nextui-org/switch": "^2.2.8",
"@nextui-org/system": "^2.4.6", "@nextui-org/system": "^2.4.6",
"@nextui-org/table": "^2.2.8",
"@nextui-org/theme": "^2.4.5", "@nextui-org/theme": "^2.4.5",
"@nextui-org/tooltip": "^2.2.7",
"@react-aria/i18n": "^3.12.4", "@react-aria/i18n": "^3.12.4",
"@react-stately/data": "^3.12.0", "@react-stately/data": "^3.12.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",

View File

@@ -1,5 +1,8 @@
"use client";
import { DateFormatter as IntlDateFormatter } from "@internationalized/date"; import { DateFormatter as IntlDateFormatter } from "@internationalized/date";
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware";
export type Task = string; export type Task = string;
@@ -19,43 +22,48 @@ export interface EventData {
id: number; id: number;
date: string; date: string;
tasks: Partial<Record<Task, string | undefined>>; tasks: Partial<Record<Task, string | undefined>>;
volunteers: Partial<Record<string, Availability>>;
description: string; description: string;
} }
interface Zustand { interface Zustand {
events: EventData[]; events: EventData[];
addEvent: (event: EventData) => void; pendingEvents: number;
user: {
userName: string;
admin: boolean;
} | null;
setEvents: (events: EventData[]) => void;
reset: (zustand?: Partial<Zustand>) => void;
setPendingEvents: (c: number) => void;
} }
const zustand = create<Zustand>()((set) => ({ const initialState = {
events: [ events: [],
user: null,
pendingEvents: 0,
};
const zustand = create<Zustand>()(
persist(
(set) => ({
...initialState,
setEvents: (events) => set({ events }),
reset: (newZustand) =>
set({
...initialState,
...newZustand,
}),
setPendingEvents: (c) => set(() => ({ pendingEvents: c })),
}),
{ {
id: 0, name: "golunteer-storage",
// date: parseDateTime("2025-01-05T11:00[Europe/Berlin]").toString(), partialize: (state) =>
date: "2025-01-05T11:00[Europe/Berlin]", Object.fromEntries(
tasks: { Object.entries(state).filter(([key]) => !["events"].includes(key)),
Audio: "Mark", ),
Livestream: undefined,
"Stream Audio": undefined,
}, },
volunteers: { Mark: "yes", Simon: "maybe", Sophie: "no" }, ),
description: "neuer Prädikant", );
},
{
id: 1,
date: "2025-01-12T11:00[Europe/Berlin]",
tasks: {
Audio: "Mark",
Livestream: undefined,
},
volunteers: { Mark: "yes", Simon: "maybe" },
description: "",
},
],
addEvent: (event: EventData) =>
set((state) => ({ events: state.events.toSpliced(-1, 0, event) })),
}));
export class DateFormatter { export class DateFormatter {
private formatter; private formatter;

View File

@@ -1,15 +1,11 @@
"use client"; "use client";
import { Divider } from "@nextui-org/divider";
import { Link } from "@nextui-org/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import React from "react"; import React from "react";
import { SiteLink } from "./layout";
import { Divider, Link } from "@nextui-org/react";
export default function Footer({ export default function Footer({ sites }: { sites: SiteLink[] }) {
sites,
}: {
sites: { href: string; text: string }[];
}) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (

View File

@@ -1,11 +1,124 @@
import { Link } from "@nextui-org/link"; "use client";
import { apiCall } from "@/lib";
import { usePathname, useRouter } from "next/navigation";
import { User } from "@carbon/icons-react";
import {
Avatar,
Badge,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
} from "@nextui-org/react";
import zustand from "@/Zustand";
import { SiteLink } from "./layout";
import { useEffect } 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 pathname = usePathname();
async function logout() {
const result = await apiCall("GET", "logout");
// if the request was successfull, redirect to the login-page
if (result.ok) {
// clear the zustand
zustand.getState().reset();
router.push("/login");
}
}
// 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);
}
})();
}, []);
export default function Header() {
return ( return (
<div className="flex justify-center"> <div>
<Link href="/" className="text-center text-8xl"> <Navbar maxWidth="full">
<h1 className="font-display-headline">Volunteer Scheduler</h1> <NavbarBrand onClick={() => router.push("/")}>
<h1 className="font-display-headline text-xl">Golunteer</h1>
</NavbarBrand>
{user !== null ? (
<>
<NavbarContent justify="center">
{sites.map((s) =>
// if the site is no admin-site or the user is an admin, render it
!s.admin || user.admin ? (
<NavbarItem key={s.href} isActive={pathname === s.href}>
<Link
href={s.href}
color={pathname === s.href ? "primary" : "foreground"}
>
{s.text}
</Link> </Link>
</NavbarItem>
) : null,
)}
</NavbarContent>
<NavbarContent justify="end">
<Dropdown placement="bottom-end">
<Badge
content={pendingEvents}
color="danger"
aria-label={`${pendingEvents} notifications`}
>
<DropdownTrigger>
<Avatar isBordered as="button" icon={<User size={32} />} />
</DropdownTrigger>
</Badge>
<DropdownMenu variant="flat">
<DropdownItem
key="profile"
className="h-14 gap-2"
textValue={`signed in as ${user.userName}`}
>
<p>Signed in as</p>
<p className="text-primary">{user.userName}</p>
</DropdownItem>
<DropdownItem
key="account"
onPress={() => router.push("/account")}
>
Account
</DropdownItem>
<DropdownItem
key="logout"
color="danger"
onPress={logout}
className="text-danger"
>
Log Out
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarContent>
</>
) : null}
</Navbar>
</div> </div>
); );
} }

View File

@@ -1,49 +1,68 @@
"use client"; "use client";
import { apiCall } from "@/lib"; import { apiCall } from "@/lib";
import { Spinner } from "@nextui-org/spinner"; import zustand from "@/Zustand";
import { Spinner } from "@nextui-org/react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
enum AuthState { enum AuthState {
Loading, LoggedIn,
LoginScreen, LoginScreen,
Unauthorized, Unauthorized,
LoggedIn, Loading,
} }
export default function Main({ children }: { children: React.ReactNode }) { export default function Main({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState(AuthState.Loading); const [auth, setAuth] = useState(AuthState.Loading);
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
if (pathname === "/login") { let loggedIn = false;
setStatus(AuthState.LoginScreen);
} else { if (zustand.getState().user === null) {
const welcomeResult = await apiCall<{ const welcomeResult = await apiCall<{
userName: string; userName: string;
loggedIn: boolean; loggedIn: boolean;
}>("GET", "welcome"); }>("GET", "welcome");
if (!welcomeResult.ok) { if (welcomeResult.ok) {
router.push("/login"); try {
} else {
const response = await welcomeResult.json(); const response = await welcomeResult.json();
if (response.loggedIn) { if (response.userName !== undefined && response.userName !== "") {
setStatus(AuthState.LoggedIn); zustand.getState().reset({ user: response });
} else {
setStatus(AuthState.Unauthorized); loggedIn = true;
} }
} catch {}
} else {
}
} else {
loggedIn = true;
}
if (pathname === "/login") {
if (loggedIn) {
router.push("/");
} else {
setAuth(AuthState.LoginScreen);
}
} else {
if (loggedIn) {
setAuth(AuthState.LoggedIn);
} else {
setAuth(AuthState.Unauthorized);
router.push("/login");
} }
} }
})(); })();
}); }, [pathname, router]);
switch (status) { switch (auth) {
case AuthState.Loading: case AuthState.Loading:
return <Spinner label="Loading..." />; return <Spinner label="Loading..." />;
case AuthState.LoggedIn: case AuthState.LoggedIn:

View File

@@ -5,10 +5,10 @@ import Event from "../components/Event/Event";
import { useState } from "react"; import { useState } from "react";
import AddEvent from "../components/Event/AddEvent"; import AddEvent from "../components/Event/AddEvent";
import zustand from "../Zustand"; import zustand from "../Zustand";
import { Button } from "@nextui-org/button";
import AssignmentTable from "@/components/Event/AssignmentTable"; import AssignmentTable from "@/components/Event/AssignmentTable";
import { useAsyncList } from "@react-stately/data"; import { useAsyncList } from "@react-stately/data";
import { apiCall } from "@/lib"; import { apiCall } from "@/lib";
import { Button } from "@nextui-org/react";
export default function EventVolunteer() { export default function EventVolunteer() {
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false); const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
@@ -18,8 +18,6 @@ export default function EventVolunteer() {
load: async () => { load: async () => {
const data = await apiCall("GET", "events"); const data = await apiCall("GET", "events");
console.debug(await data.json());
return { return {
items: [], items: [],
}; };
@@ -27,7 +25,7 @@ export default function EventVolunteer() {
}); });
return ( return (
<div className="relative flex-1 p-4"> <div className="relative flex-1">
<h2 className="mb-4 text-center text-4xl">Overview</h2> <h2 className="mb-4 text-center text-4xl">Overview</h2>
<div className="flex flex-wrap justify-center gap-4"> <div className="flex flex-wrap justify-center gap-4">
{zustand.getState().events.map((ee) => ( {zustand.getState().events.map((ee) => (

View File

@@ -4,24 +4,24 @@ import AddEvent from "@/components/Event/AddEvent";
import LocalDate from "@/components/LocalDate"; import LocalDate from "@/components/LocalDate";
import zustand, { Availability, EventData, Task, Tasks } from "@/Zustand"; import zustand, { Availability, EventData, Task, Tasks } from "@/Zustand";
import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react"; import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react";
import { Button, ButtonGroup } from "@nextui-org/button";
import { import {
Button,
ButtonGroup,
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from "@nextui-org/modal"; Select,
import { Select, SelectItem } from "@nextui-org/select"; SelectItem,
import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableColumn, TableColumn,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@nextui-org/table"; Tooltip,
import { Tooltip } from "@nextui-org/tooltip"; } from "@nextui-org/react";
import { useAsyncList } from "@react-stately/data"; import { useAsyncList } from "@react-stately/data";
import React, { Key, useState } from "react"; import React, { Key, useState } from "react";

View File

@@ -0,0 +1,64 @@
"use client";
import AddEvent from "@/components/Event/AddEvent";
import AssignmentTable from "@/components/Event/AssignmentTable";
import Event from "@/components/Event/Event";
import { apiCall } from "@/lib";
import zustand, { EventData } from "@/Zustand";
import { Add } from "@carbon/icons-react";
import { Button } from "@nextui-org/react";
import { useEffect, useState } from "react";
export default function Events() {
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
const events = zustand((state) => state.events);
const admin = zustand((state) => state.user?.admin);
useEffect(() => {
(async () => {
console.debug("query");
const data = await apiCall<EventData[]>("GET", "events/assignments");
if (data.ok) {
zustand.getState().setEvents(await data.json());
}
return {
items: [],
};
})();
}, []);
return (
<div className="relative flex-1">
<h2 className="mb-4 text-center text-4xl">Upcoming Events</h2>
<div className="flex flex-wrap justify-center gap-4">
{events.map((ee, ii) => (
<Event key={ii} event={ee}>
<AssignmentTable tasks={ee.tasks} />
</Event>
))}
</div>
{admin ? (
<>
<Button
color="primary"
isIconOnly
radius="full"
className="absolute bottom-0 right-0"
onPress={() => setShowAddItemDialogue(true)}
>
<Add size={32} />
</Button>
<AddEvent
className="border-2 border-accent-3"
isOpen={showAddItemDialogue}
onOpenChange={setShowAddItemDialogue}
/>
</>
) : null}
</div>
);
}

View File

@@ -11,12 +11,48 @@ export const metadata: Metadata = {
description: "Generated by create next app", description: "Generated by create next app",
}; };
export interface SiteLink {
text: string;
href: string;
admin?: boolean;
}
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const footerSites = [ const headerSites: SiteLink[] = [
{
text: "Overview",
href: "/",
},
{
text: "Events",
href: "/events",
},
{
text: "Assignments",
href: "/assignments",
},
{
text: "Assign Tasks",
href: "/admin/assign",
admin: true,
},
{
text: "Users",
href: "/admin/users",
admin: true,
},
{
text: "Configuration",
href: "/admin/config",
admin: true,
},
];
const footerSites: SiteLink[] = [
{ {
text: "Impressum", text: "Impressum",
href: "/impressum", href: "/impressum",
@@ -33,9 +69,9 @@ export default function RootLayout({
<NextUIProvider> <NextUIProvider>
<div className="flex min-h-screen flex-col p-4"> <div className="flex min-h-screen flex-col p-4">
<header> <header>
<Header /> <Header sites={headerSites} />
</header> </header>
<main className="flex min-h-full flex-1 flex-col"> <main className="flex min-h-full flex-1 flex-col p-4">
<Main>{children}</Main> <Main>{children}</Main>
</main> </main>
<footer className="flex h-4 justify-center gap-4"> <footer className="flex h-4 justify-center gap-4">

View File

@@ -1,22 +1,49 @@
"use client"; "use client";
import { ViewFilled, ViewOffFilled } from "@carbon/icons-react"; import CheckboxIcon from "@/components/CheckboxIcon";
import { Button } from "@nextui-org/button"; import { apiCall } from "@/lib";
import { Form } from "@nextui-org/form"; import zustand from "@/Zustand";
import { Input } from "@nextui-org/input"; import {
import { Switch } from "@nextui-org/switch"; ViewFilled,
import { useState } from "react"; ViewOffFilled,
WarningHexFilled,
} from "@carbon/icons-react";
import { Alert, Button, Form, Input } from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function Login() { export default function Login() {
const [visibility, setVisibility] = useState(false); const [visibility, setVisibility] = useState(false);
const [wrongPassword, setWrongPassword] = useState(false);
const router = useRouter();
// set login-request
async function sendLogin(e: FormEvent<HTMLFormElement>) {
const data = Object.fromEntries(new FormData(e.currentTarget));
const result = await apiCall("POST", "login", undefined, data);
if (result.ok) {
// add the user-info to the zustand
zustand.getState().reset({ user: await result.json() });
// redirect to the home-page
router.push("/");
} else {
setWrongPassword(true);
}
}
return ( return (
<div> <div>
<h2 className="mb-4 text-center text-4xl">Login</h2> <h2 className="mb-4 text-center text-4xl">Login</h2>
<Form <Form
validationBehavior="native" validationBehavior="native"
className="flex flex-col items-center gap-2" className="mx-auto flex max-w-sm flex-col items-center gap-2"
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => {
e.preventDefault();
void sendLogin(e);
}}
> >
<Input <Input
isRequired isRequired
@@ -24,7 +51,6 @@ export default function Login() {
label="Name" label="Name"
name="username" name="username"
variant="bordered" variant="bordered"
className="max-w-xs"
/> />
<Input <Input
isRequired isRequired
@@ -32,7 +58,7 @@ export default function Login() {
name="password" name="password"
autoComplete="current-password" autoComplete="current-password"
endContent={ endContent={
<Switch <CheckboxIcon
className="my-auto" className="my-auto"
startContent={<ViewFilled />} startContent={<ViewFilled />}
endContent={<ViewOffFilled />} endContent={<ViewOffFilled />}
@@ -42,9 +68,18 @@ export default function Login() {
} }
type={visibility ? "text" : "password"} type={visibility ? "text" : "password"}
variant="bordered" variant="bordered"
className="max-w-xs"
/> />
<Button className="w-full max-w-xs" color="primary" type="submit"> <Alert
title="Login failed"
description="Wrong username or password"
color="danger"
icon={<WarningHexFilled size={32} />}
hideIconWrapper
isClosable
isVisible={wrongPassword}
onVisibleChange={(v) => setWrongPassword(v)}
/>
<Button className="w-full" color="primary" type="submit">
Login Login
</Button> </Button>
</Form> </Form>

View File

@@ -1,42 +0,0 @@
import Event from "@/components/Event/Event";
import zustand, { Availabilities, Availability } from "@/Zustand";
import { Radio, RadioGroup } from "@nextui-org/radio";
function availability2Color(availability: Availability) {
switch (availability) {
case "yes":
return "success";
case "maybe":
return "warning";
default:
return "danger";
}
}
export default function OverviewPersonal() {
return (
<div>
<h2 className="mb-4 text-center text-4xl">Upcoming Events</h2>
<div className="flex flex-wrap justify-center gap-4">
{zustand.getState().events.map((ev) => (
<Event key={ev.id} event={ev}>
<RadioGroup className="mt-auto" orientation="horizontal">
{Availabilities.map((availability) => (
<Radio
value={availability}
key={availability}
color={availability2Color(availability)}
classNames={{
label: `text-${availability2Color(availability)}`,
}}
>
{availability}
</Radio>
))}
</RadioGroup>
</Event>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import OverviewPersonal from "./me/page"; import EventVolunteer from "./Overview";
export default function Home() { export default function Home() {
// return <EventVolunteer />; return <EventVolunteer />;
return <OverviewPersonal />;
} }

View File

@@ -0,0 +1,33 @@
import { SwitchProps, useSwitch, VisuallyHidden } from "@nextui-org/react";
import React from "react";
export default function CheckboxIcon(props: SwitchProps) {
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch(props);
return (
<Component {...getBaseProps()}>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: [
"h-8 w-8",
"flex items-center justify-center",
"rounded-lg bg-default-100 hover:bg-default-200",
],
})}
>
{isSelected ? props.startContent : props.endContent}
</div>
</Component>
);
}

View File

@@ -1,18 +1,19 @@
import { useState } from "react"; import { useState } from "react";
import { Add } from "@carbon/icons-react"; import { Add } from "@carbon/icons-react";
import zustand, { EventData, Task, Tasks } from "../../Zustand"; import zustand, { EventData, Task, Tasks } from "../../Zustand";
import { Button } from "@nextui-org/button";
import { Checkbox, CheckboxGroup } from "@nextui-org/checkbox";
import { DatePicker } from "@nextui-org/date-picker";
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date"; import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
import { Textarea } from "@nextui-org/input";
import { import {
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from "@nextui-org/modal"; Textarea,
} from "@nextui-org/react";
interface state { interface state {
date: ZonedDateTime; date: ZonedDateTime;

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { Card, CardBody, CardHeader } from "@nextui-org/card";
import { Divider } from "@nextui-org/divider";
import LocalDate from "../LocalDate"; import LocalDate from "../LocalDate";
import { EventData } from "@/Zustand"; import { EventData } from "@/Zustand";
import { Card, CardBody, CardHeader, Divider } from "@nextui-org/react";
import React from "react"; import React from "react";
export default function Event({ export default function Event({
@@ -27,7 +26,6 @@ export default function Event({
options={{ options={{
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short",
timeZone: "Europe/Berlin", // TODO: check with actual backend
}} }}
> >
{event.date} {event.date}

View File

@@ -1,7 +1,7 @@
"use local"; "use local";
import { DateFormatter } from "@/Zustand"; import { DateFormatter } from "@/Zustand";
import { parseZonedDateTime } from "@internationalized/date"; import { getLocalTimeZone, parseDateTime } from "@internationalized/date";
import { useLocale } from "@react-aria/i18n"; import { useLocale } from "@react-aria/i18n";
export default function LocalDate(props: { export default function LocalDate(props: {
@@ -13,7 +13,9 @@ export default function LocalDate(props: {
return ( return (
<span className={props.className}> <span className={props.className}>
{formatter.format(parseZonedDateTime(props.children).toDate())} {formatter.format(
parseDateTime(props.children).toDate(getLocalTimeZone()),
)}
</span> </span>
); );
} }

View File

@@ -1,14 +1,14 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import { nextui } from "@nextui-org/theme"; import { nextui } from "@nextui-org/theme";
const HIGHLIGHT = "#ff5053"; const HIGHLIGHT = "hsl(359,100%,65.7%)"; // #ff5053
const FOREGROUND = "#fef2ff"; const FOREGROUND = "hsl(295,100%,97.5%)"; // #fef2ff
const ACCENT1 = "#b2aaff"; const ACCENT1 = "hsl(246,100%,83.3%)"; // #b2aaff
const ACCENT2 = "#6a5fdb"; const ACCENT2 = "hsl(245,63.3%,61.6%)"; // #6a5fdb
const ACCENT3 = "#261a66"; const ACCENT3 = "hsl(249,59.4%,25.1%)"; // #261a66
const ACCENT4 = "#29114c"; const ACCENT4 = "hsl(264,63.4%,18.2%)"; // #29114c
const ACCENT5 = "#190b2f"; const ACCENT5 = "hsl(263,62.1%,11.4%)"; // #190b2f
const BACKGROUND = "#0f000a"; const BACKGROUND = "hsl(320,100%,2.9%)"; // #0f000a
export default { export default {
content: [ content: [
@@ -21,20 +21,7 @@ export default {
extend: { extend: {
colors: { colors: {
highlight: HIGHLIGHT, highlight: HIGHLIGHT,
foreground: { foreground: FOREGROUND,
DEFAULT: FOREGROUND,
"50": FOREGROUND,
"100": FOREGROUND,
"200": FOREGROUND,
"300": FOREGROUND,
"400": FOREGROUND,
"500": FOREGROUND,
"600": "#fce8ff",
"700": "#fad0fe",
"800": "#f8abfc",
"900": "#f579f9",
"950": "#eb46ef",
},
"accent-1": ACCENT1, "accent-1": ACCENT1,
"accent-2": ACCENT2, "accent-2": ACCENT2,
"accent-3": ACCENT3, "accent-3": ACCENT3,
@@ -67,45 +54,45 @@ export default {
// }, // },
primary: { primary: {
DEFAULT: ACCENT2, DEFAULT: ACCENT2,
"50": "#39357a", "50": "hsl(244,100%,9.6%)",
"100": "#42399a", "100": "hsl(244,100%,19.2%)",
"200": "#5144be", "200": "hsl(244,100%,28.8%)",
"300": ACCENT2, "300": "hsl(244,100%,38.4%)",
"400": "#6f6ee6", "400": "hsl(244,100%,46.7%)",
"500": "#8b91ee", "500": "hsl(244,92.5%,58.4%)",
"600": "#acb7f5", "600": "hsl(244,92.5%,68.8%)",
"700": "#cbd3fa", "700": "hsl(244,92.5%,79.2%)",
"800": "#e2e7fd", "800": "hsl(244,92.5%,89.6%)",
"900": "#eff3fe", "900": "hsl(245,92.3%,94.9%)",
}, },
secondary: { secondary: {
DEFAULT: ACCENT3, DEFAULT: ACCENT3,
"50": "#3b288a", "50": "hsl(249,66.7%,9.4%)",
"100": "#462fa8", "100": "hsl(249,66.7%,18.8%)",
"200": "#5538c9", "200": "hsl(249,66.7%,28.2%)",
"300": "#634add", "300": "hsl(249,66.7%,37.6%)",
"400": "#776ae8", "400": "hsl(249,66.7%,47.1%)",
"500": "#9a95f0", "500": "hsl(249,59.3%,57.6%)",
"600": "#bdbcf6", "600": "hsl(249,59.3%,68.2%)",
"700": "#dadbfa", "700": "hsl(249,59.3%,78.8%)",
"800": "#ebebfc", "800": "hsl(249,59.3%,89.4%)",
"900": "#f4f4fe", "900": "hsl(249,61.5%,94.9%)",
}, },
// background: { // background: {
// DEFAULT: BACKGROUND, // DEFAULT: BACKGROUND,
// }, // },
danger: { danger: {
DEFAULT: HIGHLIGHT, DEFAULT: HIGHLIGHT,
"50": "#fff1f1", "50": "hsl(360,84.9%,10.4%)",
"100": "#ffe1e2", "100": "hsl(359,86.5%,20.4%)",
"200": "#ffc7c8", "200": "hsl(359,86%,30.8%)",
"300": "#ffa0a2", "300": "hsl(359,86.5%,40.8%)",
"400": HIGHLIGHT, "400": "hsl(359,90.4%,51.2%)",
"500": "#f83b3e", "500": "hsl(359,90%,60.8%)",
"600": "#e51d20", "600": "hsl(359,90.6%,70.8%)",
"700": "#c11417", "700": "hsl(359,90%,80.4%)",
"800": "#a01416", "800": "hsl(360,91.8%,90.4%)",
"900": "#84181a", "900": "hsl(359,92%,95.1%)",
}, },
}, },
}, },

View File

@@ -9,5 +9,7 @@ require (
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.32.0
) )

View File

@@ -2,6 +2,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,14 +1,16 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"os" "os"
"regexp" "regexp"
"strings" "strings"
"github.com/google/uuid"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
_config "github.com/johannesbuehl/golunteer/setup/pkg/config" _config "github.com/johannesbuehl/golunteer/setup/pkg/config"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -44,7 +46,7 @@ func main() {
DBName: config.Database.Database, DBName: config.Database.Database,
} }
db, err := sql.Open("mysql", sqlConfig.FormatDSN()) db, err := sqlx.Open("mysql", sqlConfig.FormatDSN())
if err != nil { if err != nil {
exit(err) exit(err)
} }
@@ -104,7 +106,8 @@ func main() {
fmt.Println("\thashed password") fmt.Println("\thashed password")
// create an admin-user // create an admin-user
if _, err := db.Exec("INSERT INTO USERS (name, password) VALUES ('admin', ?)", passwordHash); err != nil { tokenId := uuid.NewString()
if _, err := db.Exec("INSERT INTO USERS (name, password, tokenID) VALUES ('admin', ?, ?)", passwordHash, tokenId); err != nil {
exit(err) exit(err)
} }

View File

@@ -14,7 +14,9 @@ CREATE TABLE USERS (
name varchar(64) PRIMARY KEY, name varchar(64) PRIMARY KEY,
password binary(60) NOT NULL, password binary(60) NOT NULL,
admin BOOL NOT NULL DEFAULT(false), admin BOOL NOT NULL DEFAULT(false),
tokenID varchar(64) DEFAULT NULL tokenID varchar(64) NOT NULL,
CHECK (CHAR_LENGTH(password) = 60),
CHECK (CHAR_LENGTH(tokenID) = 36)
); );
CREATE TABLE EVENTS ( CREATE TABLE EVENTS (