feat: share space

This commit is contained in:
juancwu 2026-05-03 19:05:15 +00:00
commit ed96faec8f
8 changed files with 624 additions and 5 deletions

View file

@ -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)
}

View file

@ -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")

View file

@ -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")

View file

@ -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.

View 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>
}

View 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>
}
}

View 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>
}

View file

@ -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),