diff --git a/core/api/group.go b/core/api/group.go index f01f4c1..e7b5ecc 100644 --- a/core/api/group.go +++ b/core/api/group.go @@ -261,6 +261,13 @@ type addGroupMemberRequest struct { ExpiresAt time.Time `json:"expires_at"` } +func requestAddedBy(c *gin.Context, claimed string) string { + if RequestTokenHasScope(c, "sentinel:all") && claimed != "" { + return claimed + } + return GetRequestTokenEntityID(c) +} + func AddGroupMember(c *gin.Context) { id := c.Param("id") if !requireGroupOwnerOrAdmin(c, id) { @@ -271,15 +278,55 @@ func AddGroupMember(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + group, err := service.GetGroupByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if _, err := service.GetEntityByID(req.EntityID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "entity not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } if err := validateMembershipExpiration(req.HasExpiration, req.ExpiresAt); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + source := req.Source + if source == "" { + source = string(model.GroupMemberSourceDirect) + } + if source != string(model.GroupMemberSourceDirect) && !RequestTokenHasScope(c, "sentinel:all") { + c.JSON(http.StatusForbidden, gin.H{"error": "only internal services can add synced group members"}) + return + } + if source == string(model.GroupMemberSourceDirect) && + !containsSource(group.AllowedSources, model.GroupMemberSourceDirect) && + !RequestTokenHasScope(c, "sentinel:all") { + c.JSON(http.StatusBadRequest, gin.H{"error": "direct memberships are not enabled for this group"}) + return + } + _, err = service.GetGroupMember(id, req.EntityID) + if err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "entity is already a member of this group"}) + return + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } member, err := service.CreateGroupMember(model.GroupMember{ GroupID: id, EntityID: req.EntityID, - Source: req.Source, - AddedBy: req.AddedBy, + Source: source, + AddedBy: requestAddedBy(c, req.AddedBy), HasExpiration: req.HasExpiration, ExpiresAt: req.ExpiresAt, }) @@ -339,10 +386,35 @@ func AddGroupOwner(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if _, err := service.GetGroupByID(id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if _, err := service.GetEntityByID(req.EntityID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "entity not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + _, err := service.GetGroupOwner(id, req.EntityID) + if err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "entity is already an owner of this group"}) + return + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } owner, err := service.CreateGroupOwner(model.GroupOwner{ GroupID: id, EntityID: req.EntityID, - AddedBy: req.AddedBy, + AddedBy: requestAddedBy(c, req.AddedBy), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -625,4 +697,3 @@ func DeleteJoinRequestComment(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "comment deleted"}) } - diff --git a/web/src/pages/groups/AddGroupPersonDialog.tsx b/web/src/pages/groups/AddGroupPersonDialog.tsx new file mode 100644 index 0000000..e0cd7f6 --- /dev/null +++ b/web/src/pages/groups/AddGroupPersonDialog.tsx @@ -0,0 +1,429 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Crown, Search, UserPlus } from "lucide-react" +import { useMemo, useState } from "react" +import { toast } from "sonner" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { api } from "@/lib/api" +import { + addCustom, + addPreset, + DURATION_PRESETS, + formatAbsoluteDate, + MAX_BY_UNIT, + type DurationPreset, + type DurationUnit, +} from "@/lib/duration" +import { fuzzyFilter } from "@/lib/fuzzy" + +type AddGroupPersonMode = "member" | "owner" + +const ENTITY_ID_PATTERN = /^ent_[0-9a-hjkmnp-tv-z]{26}$/i + +type UserOption = { + id: string + entity_id: string + username: string + first_name: string + last_name: string + email: string + avatar_url: string +} + +function userName(user: UserOption) { + const fullName = `${user.first_name} ${user.last_name}`.trim() + return fullName || user.username || user.entity_id +} + +function userInitials(user: UserOption) { + return userName(user) + .split(/\s+/) + .map((part) => part[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase() +} + +function selectableText(user: UserOption) { + return [ + user.first_name, + user.last_name, + user.username, + user.email, + user.entity_id, + ].filter(Boolean) +} + +function durationEnd( + mode: DurationPreset | "custom", + customAmount: number, + customUnit: DurationUnit, +) { + if (mode === "custom") { + return addCustom(new Date(), customAmount, customUnit) + } + return addPreset(new Date(), mode) +} + +export function AddGroupPersonDialog({ + open, + onOpenChange, + groupID, + groupName, + initialMode, + existingMemberEntityIDs, + existingOwnerEntityIDs, +}: { + open: boolean + onOpenChange: (open: boolean) => void + groupID: string + groupName: string + initialMode: AddGroupPersonMode + existingMemberEntityIDs: string[] + existingOwnerEntityIDs: string[] +}) { + const qc = useQueryClient() + const [mode, setMode] = useState(initialMode) + const [search, setSearch] = useState("") + const [selectedEntityID, setSelectedEntityID] = useState("") + const [durationMode, setDurationMode] = useState("1mo") + const [customAmount, setCustomAmount] = useState(7) + const [customUnit, setCustomUnit] = useState("days") + + const usersQuery = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await api.get("/users") + return res.data + }, + staleTime: 5 * 60 * 1000, + enabled: open, + }) + + const existing = useMemo( + () => new Set(mode === "member" ? existingMemberEntityIDs : existingOwnerEntityIDs), + [mode, existingMemberEntityIDs, existingOwnerEntityIDs], + ) + + const eligibleUsers = useMemo( + () => + (usersQuery.data ?? []).filter( + (user) => user.entity_id && !existing.has(user.entity_id), + ), + [usersQuery.data, existing], + ) + + const filteredUsers = useMemo(() => { + const needle = search.trim() + if (!needle) return eligibleUsers.slice(0, 8) + return fuzzyFilter(eligibleUsers, needle, selectableText).slice(0, 8) + }, [eligibleUsers, search]) + + const typedEntityID = search.trim() + const canUseTypedEntityID = + ENTITY_ID_PATTERN.test(typedEntityID) && !existing.has(typedEntityID) + const targetEntityID = selectedEntityID || (canUseTypedEntityID ? typedEntityID : "") + const selectedUser = (usersQuery.data ?? []).find((user) => user.entity_id === targetEntityID) + const memberExpiresAt = durationEnd(durationMode, customAmount, customUnit) + + const mutation = useMutation({ + mutationFn: async () => { + if (!targetEntityID) return + if (mode === "owner") { + await api.post(`/groups/${groupID}/owners`, { entity_id: targetEntityID }) + return + } + if (durationMode === "custom") { + if ( + !Number.isFinite(customAmount) || + customAmount <= 0 || + customAmount > MAX_BY_UNIT[customUnit] + ) { + throw new Error(`Pick between 1 and ${MAX_BY_UNIT[customUnit]} ${customUnit}.`) + } + } + await api.post(`/groups/${groupID}/members`, { + entity_id: targetEntityID, + source: "DIRECT", + has_expiration: true, + expires_at: memberExpiresAt.toISOString(), + }) + }, + onSuccess: async () => { + await Promise.all([ + qc.invalidateQueries({ queryKey: ["group", groupID] }), + qc.invalidateQueries({ queryKey: ["group", groupID, "members"] }), + qc.invalidateQueries({ queryKey: ["group", groupID, "owners"] }), + ]) + toast.success(mode === "member" ? "Member added" : "Owner added") + onOpenChange(false) + }, + onError: (err: unknown) => { + const message = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + (err as Error).message ?? + (mode === "member" ? "Couldn't add member." : "Couldn't add owner.") + toast.error(message) + }, + }) + + function selectMode(nextMode: AddGroupPersonMode) { + if (mutation.isPending) return + setMode(nextMode) + setSelectedEntityID("") + } + + return ( + { + if (!mutation.isPending) onOpenChange(nextOpen) + }} + > + + +
+ {mode === "member" ? ( + + ) : ( + + )} +
+ Add to {groupName} + + Choose a person and assign their group access. + +
+ +
+ + +
+ +
+ +
+ + { + setSearch(event.target.value) + setSelectedEntityID("") + }} + className="pl-9" + /> +
+
+ {usersQuery.isLoading ? ( +
+ + + +
+ ) : filteredUsers.length === 0 && !canUseTypedEntityID ? ( +

+ No available users found. +

+ ) : ( +
+ {canUseTypedEntityID && ( + + )} + {filteredUsers.map((user) => ( + + ))} +
+ )} +
+
+ + {mode === "member" && ( +
+ +
+ {DURATION_PRESETS.map((preset) => { + const active = durationMode === preset.value + return ( + + ) + })} + +
+ {durationMode === "custom" && ( +
+ + setCustomAmount(parseInt(event.target.value, 10) || 0) + } + className="w-24" + /> + +

+ Max {MAX_BY_UNIT[customUnit]} +

+
+ )} +

+ Ends {formatAbsoluteDate(memberExpiresAt.toISOString())} +

+
+ )} + + {targetEntityID && ( +
+

+ Selected +

+

+ {selectedUser ? userName(selectedUser) : targetEntityID} +

+ {selectedUser?.username && ( +

@{selectedUser.username}

+ )} +
+ )} + +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/groups/GroupDetailsPage.tsx b/web/src/pages/groups/GroupDetailsPage.tsx index e7c4898..44e405b 100644 --- a/web/src/pages/groups/GroupDetailsPage.tsx +++ b/web/src/pages/groups/GroupDetailsPage.tsx @@ -77,6 +77,7 @@ import { type GroupSource, } from "@/lib/groups" +import { AddGroupPersonDialog } from "./AddGroupPersonDialog" import { ReviewRequestDialog } from "./ReviewRequestDialog" const MEMBER_PREVIEW_COUNT = 6 @@ -343,6 +344,8 @@ export default function GroupDetailsPage() { const [customUnit, setCustomUnit] = useState("days") const [cancelling, setCancelling] = useState(false) const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false) + const [addPersonOpen, setAddPersonOpen] = useState(false) + const [addPersonMode, setAddPersonMode] = useState<"member" | "owner">("member") const groupQuery = useQuery({ queryKey: ["group", id], @@ -565,6 +568,12 @@ export default function GroupDetailsPage() { const directCount = members.filter((m) => m.source === "DIRECT").length const syncedCount = members.filter((m) => m.source === "DISCORD" || m.source === "CONDITIONAL").length + const directMembershipsEnabled = group.allowed_sources?.includes("DIRECT") ?? false + + function openAddPerson(mode: "member" | "owner") { + setAddPersonMode(mode) + setAddPersonOpen(true) + } return ( @@ -900,6 +909,20 @@ export default function GroupDetailsPage() { Can edit the group, manage members, and approve requests. Global admins inherit these permissions. + {isOwner && ( + + + + )} {ownersQuery.isLoading ? ( @@ -928,6 +951,26 @@ export default function GroupDetailsPage() { {members.length} total · {directCount} direct · {syncedCount} synced + {isOwner && ( + + + + )}
@@ -1125,6 +1168,18 @@ export default function GroupDetailsPage() { action={reviewTarget?.action ?? "approve"} reviewerEntityID={myEntityID} /> + + {addPersonOpen && ( + member.entity_id)} + existingOwnerEntityIDs={owners.map((owner) => owner.entity_id)} + /> + )} ) }