feat: share space
This commit is contained in:
parent
88c8596512
commit
ed96faec8f
8 changed files with 624 additions and 5 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
68
internal/ui/forms/invite_member.templ
Normal file
68
internal/ui/forms/invite_member.templ
Normal file
|
|
@ -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) {
|
||||
<form
|
||||
id="invite-member-form"
|
||||
hx-post={ routeurl.URL("action.app.spaces.space.members.invite", "spaceID", props.SpaceID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
if props.GeneralErr != "" {
|
||||
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||
{ props.GeneralErr }
|
||||
}
|
||||
}
|
||||
if props.SuccessMsg != "" {
|
||||
@form.Message(form.MessageProps{Variant: form.MessageVariantInfo}) {
|
||||
{ props.SuccessMsg }
|
||||
}
|
||||
}
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
@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
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
75
internal/ui/pages/join_space.templ
Normal file
75
internal/ui/pages/join_space.templ
Normal file
|
|
@ -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),
|
||||
}) {
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative">
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
@blocks.ThemeSwitcher()
|
||||
</div>
|
||||
<div class="w-full max-w-md">
|
||||
@card.Card(card.Props{Class: "rounded-sm"}) {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
You're invited
|
||||
}
|
||||
@card.Description() {
|
||||
{ props.InviterName } has invited you to join <span class="font-medium text-foreground">{ props.SpaceName }</span>.
|
||||
}
|
||||
}
|
||||
@card.Content(card.ContentProps{Class: "space-y-3"}) {
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Invitation sent to <span class="text-foreground">{ props.InviteeEmail }</span>.
|
||||
</p>
|
||||
if props.AlreadyMember {
|
||||
<p class="text-sm">You're already a member of this space.</p>
|
||||
} else if !props.IsAuthed {
|
||||
<p class="text-sm text-muted-foreground">
|
||||
You'll need to sign in or create an account to continue.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Href: "/",
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
if !props.AlreadyMember {
|
||||
<form action={ templ.SafeURL("/join/" + props.Token + "/accept") } method="POST" class="contents">
|
||||
@csrf.Token()
|
||||
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||
if props.IsAuthed {
|
||||
Join Space
|
||||
} else {
|
||||
Continue to sign in
|
||||
}
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
190
internal/ui/pages/space_members.templ
Normal file
190
internal/ui/pages/space_members.templ
Normal file
|
|
@ -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),
|
||||
) {
|
||||
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Members</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Manage who has access to { props.SpaceName }.
|
||||
</p>
|
||||
</div>
|
||||
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() {
|
||||
<ul class="divide-y">
|
||||
for _, m := range props.Members {
|
||||
@memberRow(props, m)
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
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() {
|
||||
<ul class="divide-y">
|
||||
for _, inv := range props.PendingInvites {
|
||||
@pendingInviteRow(props, inv)
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center font-medium">
|
||||
{ initial }
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">{ display }</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{ m.Email }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<span class="text-xs text-muted-foreground capitalize">{ string(m.Role) }</span>
|
||||
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
|
||||
}
|
||||
}
|
||||
<form hx-post={ routeurl.URL("action.app.spaces.space.members.remove", "spaceID", props.SpaceID, "userID", m.UserID) }>
|
||||
@button.Button(button.Props{
|
||||
Type: button.TypeSubmit,
|
||||
Variant: button.VariantDestructive,
|
||||
}) {
|
||||
Remove
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
templ pendingInviteRow(props SpaceMembersPageProps, inv *model.SpaceInvitation) {
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
|
||||
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">{ inv.Email }</p>
|
||||
<p class="text-xs text-muted-foreground">Invited { inv.CreatedAt.Format("Jan 2, 2006") } · expires { inv.ExpiresAt.Format("Jan 2, 2006") }</p>
|
||||
</div>
|
||||
</div>
|
||||
if props.IsOwner {
|
||||
<form hx-post={ routeurl.URL("action.app.spaces.space.invitations.cancel", "spaceID", props.SpaceID, "token", inv.Token) }>
|
||||
@button.Button(button.Props{
|
||||
Type: button.TypeSubmit,
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeSm,
|
||||
}) {
|
||||
Cancel invitation
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
|
@ -64,6 +64,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
|
|||
<span>Space Overview</span>
|
||||
}
|
||||
}
|
||||
@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()
|
||||
<span>Members</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue