Merge pull request 'feat/member-management' (#8) from feat/member-management into main

Reviewed-on: #8
This commit is contained in:
juancwu 2026-02-08 00:21:24 +00:00
commit 16b1e438a0
9 changed files with 510 additions and 0 deletions

1
.gitignore vendored
View file

@ -59,3 +59,4 @@ tmp/
*.db-wal *.db-wal
output.css output.css
.session

View file

@ -878,6 +878,155 @@ func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request)
ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) 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
}
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusOK)
}
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) { func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
lists, err := h.listService.GetListsForSpace(spaceID) lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil { if err != nil {

View file

@ -23,3 +23,12 @@ type SpaceMember struct {
Role Role `db:"role"` Role Role `db:"role"`
JoinedAt time.Time `db:"joined_at"` 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"`
}

View file

@ -20,6 +20,8 @@ type SpaceRepository interface {
AddMember(spaceID, userID string, role model.Role) error AddMember(spaceID, userID string, role model.Role) error
RemoveMember(spaceID, userID string) error RemoveMember(spaceID, userID string) error
IsMember(spaceID, userID string) (bool, error) IsMember(spaceID, userID string) (bool, error)
GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error)
UpdateName(spaceID, name string) error
} }
type spaceRepository struct { type spaceRepository struct {
@ -106,3 +108,23 @@ func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) {
} }
return count > 0, nil 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
}

View file

@ -152,6 +152,27 @@ func SetupRoutes(a *app.App) http.Handler {
listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler) listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess) 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 // Invite routes
createInviteHandler := middleware.RequireAuth(space.CreateInvite) createInviteHandler := middleware.RequireAuth(space.CreateInvite)
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler) createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)

View file

@ -94,6 +94,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) {
return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) 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) { func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) {
// Filter for pending only in memory or repo? // Filter for pending only in memory or repo?
// Repo returns all. // Repo returns all.

View file

@ -88,3 +88,25 @@ func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
} }
return isMember, nil 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)
}

View file

@ -80,6 +80,16 @@ templ Space(title string, space *model.Space) {
<span>Tags</span> <span>Tags</span>
} }
} }
@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"})
<span>Settings</span>
}
}
} }
} }
} }

View file

@ -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) {
<div class="space-y-6 max-w-2xl">
// 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 {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
hx-swap="none"
class="flex gap-2 items-start"
>
@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
}
</form>
} else {
<p class="text-sm">{ space.Name }</p>
}
}
}
// Members Section
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Users(icon.Props{Class: "size-5"})
Members
</div>
}
@card.Description() {
People who have access to this space.
}
}
@card.Content() {
<div class="divide-y" id="members-list">
for _, member := range members {
@MemberRow(space.ID, member, isOwner, currentUserID)
}
</div>
}
}
// Invitations Section (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Mail(icon.Props{Class: "size-5"})
Invitations
</div>
}
@card.Description() {
Invite new members and manage pending invitations.
}
}
@card.Content() {
<div class="space-y-4">
<form
hx-post={ "/app/spaces/" + space.ID + "/invites" }
hx-swap="none"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
class="flex gap-2 items-start"
>
@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
}
</form>
<div
id="pending-invites"
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
hx-trigger="refreshInvites from:body"
hx-swap="innerHTML"
>
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(space.ID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
</div>
</div>
}
}
}
</div>
@dialog.Script()
}
}
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{ string([]rune(member.Name)[0]) }
</div>
<div>
<p class="text-sm font-medium">{ member.Name }</p>
<p class="text-xs text-muted-foreground">{ member.Email }</p>
</div>
</div>
<div class="flex items-center gap-2">
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
}
}
}
}
}
}
</div>
</div>
}
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
</div>
<div>
<p class="text-sm font-medium">{ invite.Email }</p>
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
</div>
</div>
<div class="flex items-center gap-2">
@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"})
}
</div>
</div>
}
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(spaceID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
}