From 1b5c57704e0ce78b7d37b5d4baa07ca2aed05e6f Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 19:16:16 -0500 Subject: [PATCH] feat: dedicated settings page for space --- .gitignore | 1 + internal/handler/space.go | 155 ++++++++++++ internal/model/space.go | 9 + internal/repository/space.go | 22 ++ internal/routes/routes.go | 21 ++ internal/service/invite.go | 13 + internal/service/space.go | 22 ++ internal/ui/layouts/space.templ | 10 + internal/ui/pages/app_space_settings.templ | 263 +++++++++++++++++++++ 9 files changed, 516 insertions(+) create mode 100644 internal/ui/pages/app_space_settings.templ 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 Section + @card.Card() { + @card.Header() { + @card.Title() { + Space Name + } + @card.Description() { + if isOwner { + Update the name of this space. + } else { + The name of this space. + } + } + } + @card.Content() { + if isOwner { +
+ @csrf.Token() + @input.Input(input.Props{ + Name: "name", + Value: space.Name, + Attributes: templ.Attributes{ + "autocomplete": "off", + "required": true, + }, + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + Save + } +
+ } else { +

{ space.Name }

+ } + } + } + // Members Section + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.Users(icon.Props{Class: "size-5"}) + Members +
+ } + @card.Description() { + People who have access to this space. + } + } + @card.Content() { +
+ for _, member := range members { + @MemberRow(space.ID, member, isOwner, currentUserID) + } +
+ } + } + // Invitations Section (owner only) + if isOwner { + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.Mail(icon.Props{Class: "size-5"}) + Invitations +
+ } + @card.Description() { + Invite new members and manage pending invitations. + } + } + @card.Content() { +
+
+ @csrf.Token() + @input.Input(input.Props{ + Name: "email", + Placeholder: "Email address...", + Attributes: templ.Attributes{ + "type": "email", + "autocomplete": "off", + "required": true, + }, + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + @icon.UserPlus(icon.Props{Class: "size-4"}) + Invite + } +
+
+ if len(pendingInvites) > 0 { +

Pending invitations

+
+ for _, invite := range pendingInvites { + @PendingInviteRow(space.ID, invite) + } +
+ } else { +

No pending invitations.

+ } +
+
+ } + } + } +
+ @dialog.Script() + } +} + +templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) { +
+
+
+ { string([]rune(member.Name)[0]) } +
+
+

{ member.Name }

+

{ member.Email }

+
+
+
+ if member.Role == model.RoleOwner { + @badge.Badge(badge.Props{Variant: badge.VariantDefault}) { + @icon.Crown(icon.Props{Class: "size-3"}) + Owner + } + } else { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + Member + } + } + if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner { + {{ dialogID := "remove-member-dialog-" + member.UserID }} + @dialog.Dialog(dialog.Props{ID: dialogID}) { + @dialog.Trigger() { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Type: button.TypeButton, + }) { + @icon.UserMinus(icon.Props{Class: "size-4 text-destructive"}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Remove member + } + @dialog.Description() { + Are you sure you want to remove { member.Name } from this space? They will lose access immediately. + } + } + @dialog.Footer() { + @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantOutline, + Type: button.TypeButton, + }) { + Cancel + } + } + @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Type: button.TypeButton, + Attributes: templ.Attributes{ + "hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID, + "hx-target": "#member-" + member.UserID, + "hx-swap": "outerHTML", + "hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`, + }, + }) { + Remove + } + } + } + } + } + } +
+
+} + +templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) { +
+
+
+ @icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"}) +
+
+

{ invite.Email }

+

Sent { invite.CreatedAt.Format("Jan 02, 2006") }

+
+
+
+ @badge.Badge(badge.Props{Variant: badge.VariantOutline}) { + Pending + } + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Type: button.TypeButton, + Attributes: templ.Attributes{ + "hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token, + "hx-target": "#invite-" + invite.Token, + "hx-swap": "outerHTML", + "hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`, + }, + }) { + @icon.X(icon.Props{Class: "size-4 text-destructive"}) + } +
+
+} + +templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) { + if len(pendingInvites) > 0 { +

Pending invitations

+
+ for _, invite := range pendingInvites { + @PendingInviteRow(spaceID, invite) + } +
+ } else { +

No pending invitations.

+ } +}