diff --git a/.gitignore b/.gitignore index 32b4d3b..45b63b8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ tmp/ *.db-wal output.css +.session diff --git a/internal/handler/space.go b/internal/handler/space.go index 72b2a8e..2a2614a 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -878,6 +878,161 @@ func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) } +func (h *SpaceHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to get space", "error", err, "space_id", spaceID) + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + members, err := h.spaceService.GetMembers(spaceID) + if err != nil { + slog.Error("failed to get members", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + isOwner := space.OwnerID == user.ID + + var pendingInvites []*model.SpaceInvitation + if isOwner { + pendingInvites, err = h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + + ui.Render(w, r, pages.SpaceSettingsPage(space, members, pendingInvites, isOwner, user.ID)) +} + +func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + name := r.FormValue("name") + if name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + if err := h.spaceService.UpdateSpaceName(spaceID, name); err != nil { + slog.Error("failed to update space name", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, toast.Toast(toast.Props{ + Title: "Space renamed", + Description: "Space name has been updated.", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + userID := r.PathValue("userID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if userID == user.ID { + http.Error(w, "Cannot remove yourself", http.StatusBadRequest) + return + } + + if err := h.spaceService.RemoveMember(spaceID, userID); err != nil { + slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + token := r.PathValue("token") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := h.inviteService.CancelInvite(token); err != nil { + slog.Error("failed to cancel invite", "error", err, "space_id", spaceID, "token", token) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + pendingInvites, err := h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites)) +} + func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) { lists, err := h.listService.GetListsForSpace(spaceID) if err != nil { diff --git a/internal/model/space.go b/internal/model/space.go index 93e8c9e..f18856c 100644 --- a/internal/model/space.go +++ b/internal/model/space.go @@ -23,3 +23,12 @@ type SpaceMember struct { Role Role `db:"role"` JoinedAt time.Time `db:"joined_at"` } + +type SpaceMemberWithProfile struct { + SpaceID string `db:"space_id"` + UserID string `db:"user_id"` + Role Role `db:"role"` + JoinedAt time.Time `db:"joined_at"` + Name string `db:"name"` + Email string `db:"email"` +} diff --git a/internal/repository/space.go b/internal/repository/space.go index 0f7db6e..a01f38c 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -20,6 +20,8 @@ type SpaceRepository interface { AddMember(spaceID, userID string, role model.Role) error RemoveMember(spaceID, userID string) error IsMember(spaceID, userID string) (bool, error) + GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) + UpdateName(spaceID, name string) error } type spaceRepository struct { @@ -106,3 +108,23 @@ func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) { } return count > 0, nil } + +func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) { + var members []*model.SpaceMemberWithProfile + query := ` + SELECT sm.space_id, sm.user_id, sm.role, sm.joined_at, + p.name, u.email + FROM space_members sm + JOIN users u ON sm.user_id = u.id + JOIN profiles p ON sm.user_id = p.user_id + WHERE sm.space_id = $1 + ORDER BY sm.role DESC, sm.joined_at ASC;` + err := r.db.Select(&members, query, spaceID) + return members, err +} + +func (r *spaceRepository) UpdateName(spaceID, name string) error { + query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;` + _, err := r.db.Exec(query, name, time.Now(), spaceID) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index c64dbb0..be2bc19 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -151,6 +151,27 @@ func SetupRoutes(a *app.App) http.Handler { listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler) mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess) + // Settings routes + settingsPageHandler := middleware.RequireAuth(space.SettingsPage) + settingsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(settingsPageHandler) + mux.Handle("GET /app/spaces/{spaceID}/settings", settingsPageWithAccess) + + updateSpaceNameHandler := middleware.RequireAuth(space.UpdateSpaceName) + updateSpaceNameWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateSpaceNameHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/settings/name", updateSpaceNameWithAccess) + + removeMemberHandler := middleware.RequireAuth(space.RemoveMember) + removeMemberWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(removeMemberHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/members/{userID}", removeMemberWithAccess) + + cancelInviteHandler := middleware.RequireAuth(space.CancelInvite) + cancelInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(cancelInviteHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/invites/{token}", cancelInviteWithAccess) + + getPendingInvitesHandler := middleware.RequireAuth(space.GetPendingInvites) + getPendingInvitesWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(getPendingInvitesHandler) + mux.Handle("GET /app/spaces/{spaceID}/settings/invites", getPendingInvitesWithAccess) + // Invite routes createInviteHandler := middleware.RequireAuth(space.CreateInvite) createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler) diff --git a/internal/service/invite.go b/internal/service/invite.go index d12d040..1907a03 100644 --- a/internal/service/invite.go +++ b/internal/service/invite.go @@ -94,6 +94,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) { return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) } +func (s *InviteService) CancelInvite(token string) error { + invite, err := s.inviteRepo.GetByToken(token) + if err != nil { + return err + } + + if invite.Status != model.InvitationStatusPending { + return errors.New("invitation is not pending") + } + + return s.inviteRepo.Delete(token) +} + func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) { // Filter for pending only in memory or repo? // Repo returns all. diff --git a/internal/service/space.go b/internal/service/space.go index 5a3fd59..d3990b2 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -88,3 +88,25 @@ func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) { } return isMember, nil } + +// GetMembers returns all members of a space with their profile info. +func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) { + members, err := s.spaceRepo.GetMembers(spaceID) + if err != nil { + return nil, fmt.Errorf("failed to get members: %w", err) + } + return members, nil +} + +// RemoveMember removes a member from a space. +func (s *SpaceService) RemoveMember(spaceID, userID string) error { + return s.spaceRepo.RemoveMember(spaceID, userID) +} + +// UpdateSpaceName updates the name of a space. +func (s *SpaceService) UpdateSpaceName(spaceID, name string) error { + if name == "" { + return fmt.Errorf("space name cannot be empty") + } + return s.spaceRepo.UpdateName(spaceID, name) +} diff --git a/internal/ui/layouts/space.templ b/internal/ui/layouts/space.templ index f05ffe7..7727fe4 100644 --- a/internal/ui/layouts/space.templ +++ b/internal/ui/layouts/space.templ @@ -80,6 +80,16 @@ templ Space(title string, space *model.Space) { Tags } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: "/app/spaces/" + space.ID + "/settings", + IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings", + Tooltip: "Settings", + }) { + @icon.Settings(icon.Props{Class: "size-4"}) + Settings + } + } } } } diff --git a/internal/ui/pages/app_space_settings.templ b/internal/ui/pages/app_space_settings.templ new file mode 100644 index 0000000..a33e19d --- /dev/null +++ b/internal/ui/pages/app_space_settings.templ @@ -0,0 +1,263 @@ +package pages + +import ( + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +) + +templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) { + @layouts.Space("Settings", space) { +
{ space.Name }
+ } + } + } + // Members Section + @card.Card() { + @card.Header() { + @card.Title() { +No pending invitations.
+ } +{ member.Name }
+{ member.Email }
+{ invite.Email }
+Sent { invite.CreatedAt.Format("Jan 02, 2006") }
+No pending invitations.
+ } +}