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) { +
+
+ if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + if props.SuccessMsg != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantInfo}) { + { props.SuccessMsg } + } + } +
+ @form.Item(form.ItemProps{Class: "flex-1"}) { + @form.Label(form.LabelProps{For: "email", Class: "sr-only"}) { + Email + } + @input.Input(input.Props{ + ID: "email", + Name: "email", + Type: input.TypeEmail, + Placeholder: "person@example.com", + Class: "rounded-sm", + Value: props.Email, + HasError: props.EmailErr != "", + Required: true, + Attributes: templ.Attributes{ + "autocomplete": "off", + }, + }) + if props.EmailErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.EmailErr } + } + } + } + @button.Button(button.Props{Type: button.TypeSubmit, Class: "shrink-0"}) { + Send invitation + } +
+

+ The recipient will get an email with a link to join. They can review the invitation before deciding to join, and create an account if they don't have one yet. +

+
+
+} 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), + }) { +
+
+ @blocks.ThemeSwitcher() +
+
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + You're invited + } + @card.Description() { + { props.InviterName } has invited you to join { props.SpaceName }. + } + } + @card.Content(card.ContentProps{Class: "space-y-3"}) { +

+ 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 { +
+ @csrf.Token() + @button.Button(button.Props{Type: button.TypeSubmit}) { + if props.IsAuthed { + Join Space + } else { + Continue to sign in + } + } +
+ } + } + } +
+
+ } +} diff --git a/internal/ui/pages/space_members.templ b/internal/ui/pages/space_members.templ new file mode 100644 index 0000000..62a0844 --- /dev/null +++ b/internal/ui/pages/space_members.templ @@ -0,0 +1,190 @@ +package pages + +import "strconv" +import "strings" + +import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +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/dialog" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + +type SpaceMembersPageProps struct { + SpaceID string + SpaceName string + OwnerID string + CurrentUserID string + IsOwner bool + Members []*model.SpaceMemberWithProfile + PendingInvites []*model.SpaceInvitation + InviteForm forms.InviteMemberProps +} + +templ SpaceMembersPage(props SpaceMembersPageProps) { + @layouts.AppWithBreadcrumb( + "Members", + spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Members"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + ) { +
+
+

Members

+

+ Manage who has access to { props.SpaceName }. +

+
+ if props.IsOwner { + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + Invite a member + } + @card.Description() { + Send an invitation by email. + } + } + @card.Content() { + @forms.InviteMember(props.InviteForm) + } + } + } + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + { memberCountLabel(len(props.Members)) } + } + } + @card.Content() { + + } + } + if len(props.PendingInvites) > 0 { + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + Pending invitations + } + @card.Description() { + These people have been invited but haven't joined yet. + } + } + @card.Content() { + + } + } + } +
+ } +} + +func memberCountLabel(n int) string { + if n == 1 { + return "1 member" + } + return strconv.Itoa(n) + " members" +} + +templ memberRow(props SpaceMembersPageProps, m *model.SpaceMemberWithProfile) { + {{ + display := m.Email + if m.Name != nil && *m.Name != "" { + display = *m.Name + } + initial := "?" + if display != "" { + initial = strings.ToUpper(string(display[0])) + } + isOwner := m.UserID == props.OwnerID + isSelf := m.UserID == props.CurrentUserID + canRemove := props.IsOwner && !isOwner && !isSelf + }} +
  • +
    +
    + { initial } +
    +
    +

    { display }

    +

    { m.Email }

    +
    +
    +
    + { string(m.Role) } + if canRemove { + @dialog.Dialog() { + @dialog.Trigger() { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Attributes: templ.Attributes{"aria-label": "Remove member"}, + }) { + @icon.Trash2(icon.Props{Class: "size-4 text-destructive"}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Remove { display }? + } + @dialog.Description() { + They'll lose access to this space immediately. You can re-invite them later. + } + } + @dialog.Footer(dialog.FooterProps{Class: "mt-2"}) { + @dialog.Close() { + @button.Button(button.Props{Variant: button.VariantOutline}) { + Cancel + } + } +
    + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantDestructive, + }) { + Remove + } +
    + } + } + } + } +
    +
  • +} + +templ pendingInviteRow(props SpaceMembersPageProps, inv *model.SpaceInvitation) { +
  • +
    +
    + @icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"}) +
    +
    +

    { inv.Email }

    +

    Invited { inv.CreatedAt.Format("Jan 2, 2006") } ยท expires { inv.ExpiresAt.Format("Jan 2, 2006") }

    +
    +
    + if props.IsOwner { +
    + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantGhost, + Size: button.SizeSm, + }) { + Cancel invitation + } +
    + } +
  • +} diff --git a/internal/ui/pages/space_overview.templ b/internal/ui/pages/space_overview.templ index c7a4d52..41b2450 100644 --- a/internal/ui/pages/space_overview.templ +++ b/internal/ui/pages/space_overview.templ @@ -64,6 +64,16 @@ templ spaceSpecificSidebarContent(spaceID string) { Space Overview } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: routeurl.URL("page.app.spaces.space.members", "spaceID", spaceID), + IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.members", "spaceID", spaceID), + Tooltip: "Members", + }) { + @icon.Users() + Members + } + } @sidebar.MenuItem() { @sidebar.MenuButton(sidebar.MenuButtonProps{ Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),