diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 4c1e342..550df29 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -199,8 +199,55 @@ func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) func (h *authHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - user := ctxkeys.User(r.Context()) + invite, err := h.inviteService.GetByToken(token) + if err != nil { + slog.Warn("invite lookup failed", "error", err, "token", token) + ui.Render(w, r, pages.NotFound()) + return + } + + if invite.Invitation.Status != model.InvitationStatusPending || time.Now().After(invite.Invitation.ExpiresAt) { + ui.RenderError(w, r, "This invitation is no longer valid.", http.StatusGone) + return + } + + user := ctxkeys.User(r.Context()) + alreadyMember := false + if user != nil { + isMember, err := h.spaceService.IsMember(user.ID, invite.Invitation.SpaceID) + if err != nil { + slog.Error("failed to check membership", "error", err, "user_id", user.ID) + } + alreadyMember = isMember + } + + ui.Render(w, r, pages.JoinSpaceConfirm(pages.JoinSpaceConfirmProps{ + Token: token, + SpaceName: invite.SpaceName, + InviterName: invite.InviterName, + InviteeEmail: invite.Invitation.Email, + IsAuthed: user != nil, + AlreadyMember: alreadyMember, + })) +} + +func (h *authHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + invite, err := h.inviteService.GetByToken(token) + if err != nil { + slog.Warn("invite lookup failed", "error", err, "token", token) + ui.Render(w, r, pages.NotFound()) + return + } + + if invite.Invitation.Status != model.InvitationStatusPending || time.Now().After(invite.Invitation.ExpiresAt) { + ui.RenderError(w, r, "This invitation is no longer valid.", http.StatusGone) + return + } + + user := ctxkeys.User(r.Context()) if user != nil { spaceID, err := h.inviteService.AcceptInvite(token, user.ID) if err != nil { @@ -208,12 +255,11 @@ func (h *authHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { ui.RenderError(w, r, "Failed to join space: "+err.Error(), http.StatusUnprocessableEntity) return } - - http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther) + http.Redirect(w, r, "/app/spaces/"+spaceID+"/overview", http.StatusSeeOther) return } - // Not logged in: set cookie and redirect to auth + // Not logged in: set cookie and redirect to auth so they can log in or sign up http.SetCookie(w, &http.Cookie{ Name: "pending_invite", Value: token, @@ -222,5 +268,5 @@ func (h *authHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { HttpOnly: true, SameSite: http.SameSiteLaxMode, }) - http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect) + http.Redirect(w, r, "/auth?invite=true", http.StatusSeeOther) } diff --git a/internal/handler/space.go b/internal/handler/space.go index 5ab7921..192ca67 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" "strconv" @@ -15,6 +16,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" "git.juancwu.dev/juancwu/budgit/internal/ui/forms" "git.juancwu.dev/juancwu/budgit/internal/ui/pages" + "git.juancwu.dev/juancwu/budgit/internal/validation" "github.com/shopspring/decimal" ) @@ -22,17 +24,20 @@ type spaceHandler struct { spaceService *service.SpaceService accountService *service.AccountService transactionService *service.TransactionService + inviteService *service.InviteService } func NewSpaceHandler( spaceService *service.SpaceService, accountService *service.AccountService, transactionService *service.TransactionService, + inviteService *service.InviteService, ) *spaceHandler { return &spaceHandler{ spaceService: spaceService, accountService: accountService, transactionService: transactionService, + inviteService: inviteService, } } @@ -451,6 +456,158 @@ func (h *spaceHandler) HandleDeleteSpace(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } +func (h *spaceHandler) SpaceMembersPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.Render(w, r, pages.NotFound()) + return + } + + members, err := h.spaceService.GetMembers(spaceID) + if err != nil { + slog.Error("failed to load members", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load members", http.StatusInternalServerError) + return + } + + pending, err := h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to load pending invites", "error", err, "space_id", spaceID) + pending = nil + } + + user := ctxkeys.User(r.Context()) + currentUserID := "" + if user != nil { + currentUserID = user.ID + } + isOwner := user != nil && user.ID == space.OwnerID + + ui.Render(w, r, pages.SpaceMembersPage(pages.SpaceMembersPageProps{ + SpaceID: space.ID, + SpaceName: space.Name, + OwnerID: space.OwnerID, + CurrentUserID: currentUserID, + IsOwner: isOwner, + Members: members, + PendingInvites: pending, + InviteForm: forms.InviteMemberProps{SpaceID: space.ID}, + })) +} + +func (h *spaceHandler) HandleInviteMember(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + ui.RenderError(w, r, "Space not found", http.StatusNotFound) + return + } + + user := ctxkeys.User(r.Context()) + if user == nil || user.ID != space.OwnerID { + ui.RenderError(w, r, "Forbidden", http.StatusForbidden) + return + } + + emailInput := strings.TrimSpace(r.FormValue("email")) + formProps := forms.InviteMemberProps{ + SpaceID: spaceID, + Email: emailInput, + } + + if emailInput == "" { + formProps.EmailErr = "Email is required." + ui.Render(w, r, forms.InviteMember(formProps)) + return + } + if err := validation.ValidateEmail(emailInput); err != nil { + formProps.EmailErr = "Enter a valid email address." + ui.Render(w, r, forms.InviteMember(formProps)) + return + } + + if _, err := h.inviteService.CreateInvite(spaceID, user.ID, emailInput); err != nil { + switch { + case errors.Is(err, service.ErrInviteSelf): + formProps.EmailErr = "You can't invite yourself." + case errors.Is(err, service.ErrInviteAlreadyMember): + formProps.EmailErr = "This person is already a member." + case errors.Is(err, service.ErrInviteAlreadyPending): + formProps.EmailErr = "An invitation is already pending for this email." + default: + slog.Error("failed to create invite", "error", err, "space_id", spaceID) + formProps.GeneralErr = "Something went wrong. Please try again." + } + ui.Render(w, r, forms.InviteMember(formProps)) + return + } + + formProps.Email = "" + formProps.SuccessMsg = "Invitation sent to " + emailInput + "." + w.Header().Set("HX-Trigger", "members:refresh") + ui.Render(w, r, forms.InviteMember(formProps)) +} + +func (h *spaceHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + userID := r.PathValue("userID") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + ui.RenderError(w, r, "Space not found", http.StatusNotFound) + return + } + + user := ctxkeys.User(r.Context()) + if user == nil || user.ID != space.OwnerID { + ui.RenderError(w, r, "Forbidden", http.StatusForbidden) + return + } + if userID == space.OwnerID { + ui.RenderError(w, r, "Cannot remove the owner", 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) + ui.RenderError(w, r, "Failed to remove member", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) +} + +func (h *spaceHandler) HandleCancelInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + token := r.PathValue("token") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + ui.RenderError(w, r, "Space not found", http.StatusNotFound) + return + } + + user := ctxkeys.User(r.Context()) + if user == nil || user.ID != space.OwnerID { + ui.RenderError(w, r, "Forbidden", http.StatusForbidden) + return + } + + if err := h.inviteService.CancelInvite(token); err != nil { + slog.Error("failed to cancel invite", "error", err, "token", token) + ui.RenderError(w, r, "Failed to cancel invitation", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) +} + func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 52d63ea..a02fccd 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -19,7 +19,7 @@ func SetupRoutes(a *app.App) http.Handler { authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) homeH := handler.NewHomeHandler() settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService) - spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService) + spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.InviteService) redirectH := handler.NewRedirectHandler() r := router.New() @@ -54,6 +54,7 @@ func SetupRoutes(a *app.App) http.Handler { r.Get("/privacy", homeH.PrivacyPage).Name("page.public.privacy") r.Get("/terms", homeH.TermsPage).Name("page.public.terms") r.Get("/join/{token}", authH.JoinSpace).Name("page.public.join-space") + r.Post("/join/{token}/accept", authH.AcceptInvite).Name("action.public.join-space.accept") // Permanent redirects r.Get("/app/dashboard", redirectH.Spaces) @@ -95,6 +96,10 @@ func SetupRoutes(a *app.App) http.Handler { g.Get("/settings", spaceH.SpaceSettingsPage).Name("page.app.spaces.space.settings") g.Post("/settings/rename", spaceH.HandleRenameSpace).Name("action.app.spaces.space.settings.rename") g.Post("/settings/delete", spaceH.HandleDeleteSpace).Name("action.app.spaces.space.settings.delete") + g.Get("/members", spaceH.SpaceMembersPage).Name("page.app.spaces.space.members") + g.Post("/members/invite", spaceH.HandleInviteMember).Name("action.app.spaces.space.members.invite") + g.Post("/members/{userID}/remove", spaceH.HandleRemoveMember).Name("action.app.spaces.space.members.remove") + g.Post("/invitations/{token}/cancel", spaceH.HandleCancelInvite).Name("action.app.spaces.space.invitations.cancel") g.Get("/accounts/create", spaceH.SpaceCreateAccountPage).Name("page.app.spaces.space.accounts.create") g.Post("/accounts/create", spaceH.HandleCreateAccount).Name("action.app.spaces.space.accounts.create") diff --git a/internal/service/invite.go b/internal/service/invite.go index d281c15..574b419 100644 --- a/internal/service/invite.go +++ b/internal/service/invite.go @@ -2,6 +2,7 @@ package service import ( "errors" + "strings" "time" "git.juancwu.dev/juancwu/budgit/internal/model" @@ -9,6 +10,12 @@ import ( "github.com/google/uuid" ) +var ( + ErrInviteAlreadyMember = errors.New("user is already a member of this space") + ErrInviteAlreadyPending = errors.New("an invitation is already pending for this email") + ErrInviteSelf = errors.New("you cannot invite yourself") +) + type InviteService struct { inviteRepo repository.InvitationRepository spaceRepo repository.SpaceRepository @@ -26,12 +33,40 @@ func NewInviteService(ir repository.InvitationRepository, sr repository.SpaceRep } func (s *InviteService) CreateInvite(spaceID, inviterID, email string) (*model.SpaceInvitation, error) { + email = strings.ToLower(strings.TrimSpace(email)) + // Check if space exists space, err := s.spaceRepo.ByID(spaceID) if err != nil { return nil, err } + // Block inviting an already-existing member + if existingUser, err := s.userRepo.ByEmail(email); err == nil && existingUser != nil { + if existingUser.ID == inviterID { + return nil, ErrInviteSelf + } + isMember, err := s.spaceRepo.IsMember(spaceID, existingUser.ID) + if err != nil { + return nil, err + } + if isMember { + return nil, ErrInviteAlreadyMember + } + } + + // Block duplicate pending invites for the same email + existing, err := s.inviteRepo.GetBySpaceID(spaceID) + if err != nil { + return nil, err + } + now := time.Now() + for _, inv := range existing { + if inv.Status == model.InvitationStatusPending && strings.EqualFold(inv.Email, email) && inv.ExpiresAt.After(now) { + return nil, ErrInviteAlreadyPending + } + } + token := uuid.NewString() // Or a more secure token generator expiresAt := time.Now().Add(48 * time.Hour) @@ -101,6 +136,39 @@ func (s *InviteService) CancelInvite(token string) error { return s.inviteRepo.Delete(token) } +type InviteContext struct { + Invitation *model.SpaceInvitation + SpaceName string + InviterName string +} + +func (s *InviteService) GetByToken(token string) (*InviteContext, error) { + invite, err := s.inviteRepo.GetByToken(token) + if err != nil { + return nil, err + } + + space, err := s.spaceRepo.ByID(invite.SpaceID) + if err != nil { + return nil, err + } + + inviterName := "Someone" + if inviter, err := s.userRepo.ByID(invite.InviterID); err == nil && inviter != nil { + if inviter.Name != nil && *inviter.Name != "" { + inviterName = *inviter.Name + } else { + inviterName = inviter.Email + } + } + + return &InviteContext{ + Invitation: invite, + SpaceName: space.Name, + InviterName: inviterName, + }, nil +} + func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) { // Filter for pending only in memory or repo? // Repo returns all. diff --git a/internal/ui/forms/invite_member.templ b/internal/ui/forms/invite_member.templ new file mode 100644 index 0000000..c3ec5e0 --- /dev/null +++ b/internal/ui/forms/invite_member.templ @@ -0,0 +1,68 @@ +package forms + +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + +type InviteMemberProps struct { + SpaceID string + + Email string + + EmailErr string + GeneralErr string + SuccessMsg string +} + +templ InviteMember(props InviteMemberProps) { +
+} diff --git a/internal/ui/pages/join_space.templ b/internal/ui/pages/join_space.templ new file mode 100644 index 0000000..a53e6ee --- /dev/null +++ b/internal/ui/pages/join_space.templ @@ -0,0 +1,75 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" +import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type JoinSpaceConfirmProps struct { + Token string + SpaceName string + InviterName string + InviteeEmail string + IsAuthed bool + AlreadyMember bool +} + +templ JoinSpaceConfirm(props JoinSpaceConfirmProps) { + @layouts.Base(layouts.SEOProps{ + Title: "Join " + props.SpaceName, + Description: "Accept invitation to join a space.", + Path: ctxkeys.URLPath(ctx), + }) { ++ Invitation sent to { props.InviteeEmail }. +
+ if props.AlreadyMember { +You're already a member of this space.
+ } else if !props.IsAuthed { ++ You'll need to sign in or create an account to continue. +
+ } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Href: "/", + }) { + Cancel + } + if !props.AlreadyMember { + + } + } + } ++ Manage who has access to { props.SpaceName }. +
+{ display }
+{ m.Email }
+{ inv.Email }
+Invited { inv.CreatedAt.Format("Jan 2, 2006") } ยท expires { inv.ExpiresAt.Format("Jan 2, 2006") }
+