"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import useTags from "@/features/tags/hooks/use-tags"; import useGroupSettings from "@/features/groups/hooks/use-group-settings"; import useGroupMembers from "@/features/groups/hooks/use-group-members"; import useGroupInvites from "@/features/groups/hooks/use-group-invites"; import useGroupAudit from "@/features/groups/hooks/use-group-audit"; import { useGroupsContext } from "@/hooks/groups-context"; import TagInput from "@/components/tag-input"; import { useNotificationsContext } from "@/hooks/notifications-context"; import ConfirmSlideModal from "@/components/confirm-slide-modal"; import ConfirmRetypeModal from "@/components/confirm-retype-modal"; import { groupsDelete, groupsRename } from "@/lib/client/groups"; import ToggleButtonGroup from "@/components/toggle-button-group"; export default function GroupSettingsContent({ groupId }: { groupId: number }) { const router = useRouter(); const { groups, activeGroupId, setActiveGroup } = useGroupsContext(); const { notify } = useNotificationsContext(); const group = useMemo(() => groups.find(item => item.id === groupId) || null, [groups, groupId]); const role = group?.role; const isAdmin = role === "GROUP_ADMIN" || role === "GROUP_OWNER"; const isOwner = role === "GROUP_OWNER"; const canViewAudit = isAdmin; const hasSyncedActiveRef = useRef(false); const lastGroupIdRef = useRef(groupId); useEffect(() => { if (lastGroupIdRef.current !== groupId) { lastGroupIdRef.current = groupId; hasSyncedActiveRef.current = false; } if (!groupId || hasSyncedActiveRef.current) return; if (activeGroupId !== groupId) setActiveGroup(groupId); hasSyncedActiveRef.current = true; }, [activeGroupId, groupId, setActiveGroup]); const { tags, addTags, removeTag } = useTags(activeGroupId === groupId ? groupId : null); const { settings, updateSettings } = useGroupSettings(activeGroupId === groupId ? groupId : null); const { members, requests, currentUserId, approve, deny, promote, demote, kick, transferOwner, leave } = useGroupMembers(activeGroupId === groupId ? groupId : null); const { links, create: createInvite, revoke: revokeInvite, revive: reviveInvite, remove: deleteInvite } = useGroupInvites(activeGroupId === groupId ? groupId : null); const { events } = useGroupAudit(activeGroupId === groupId && canViewAudit ? groupId : null); const [pendingTags, setPendingTags] = useState([]); const [toggleRemoveTags, setToggleRemoveTags] = useState([]); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [renameValue, setRenameValue] = useState(group?.name || ""); const [renameModalOpen, setRenameModalOpen] = useState(false); const [confirmRenameOpen, setConfirmRenameOpen] = useState(false); const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false); const [confirmKick, setConfirmKick] = useState<{ userId: number; name: string } | null>(null); const [confirmTransfer, setConfirmTransfer] = useState<{ userId: number; name: string } | null>(null); const [confirmDeleteGroupOpen, setConfirmDeleteGroupOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [tagModalOpen, setTagModalOpen] = useState(false); const [showAllTags, setShowAllTags] = useState(false); const [inviteTtlDays, setInviteTtlDays] = useState(7); const [inviteMaxUses, setInviteMaxUses] = useState<"UNLIMITED" | "SINGLE">("UNLIMITED"); const [auditOpen, setAuditOpen] = useState(false); const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<{ id: number; token: string } | null>(null); const [auditFilters, setAuditFilters] = useState(["members", "tags", "settings", "entries"] as Array<"members" | "tags" | "settings" | "entries">); const [auditQuery, setAuditQuery] = useState(""); const [auditFrom, setAuditFrom] = useState(""); const [auditTo, setAuditTo] = useState(""); const [auditLimit, setAuditLimit] = useState(10); const [localAllowMemberTagManage, setLocalAllowMemberTagManage] = useState(settings.allowMemberTagManage); const [localJoinPolicy, setLocalJoinPolicy] = useState(settings.joinPolicy); const canManageTags = role === "GROUP_ADMIN" || role === "GROUP_OWNER" || localAllowMemberTagManage; const adminCount = members.filter(member => member.role === "GROUP_ADMIN" || member.role === "GROUP_OWNER").length; const isLastAdmin = isAdmin && adminCount <= 1; useEffect(() => { setRenameValue(group?.name || ""); }, [group?.name]); useEffect(() => { setLocalAllowMemberTagManage(settings.allowMemberTagManage); setLocalJoinPolicy(settings.joinPolicy); }, [settings.allowMemberTagManage, settings.joinPolicy]); async function handleSaveTags() { if (!pendingTags.length) return; const ok = await addTags(pendingTags); if (ok) { notify({ title: "Tags updated", message: pendingTags.join(", "), tone: "success" }); setPendingTags([]); } } async function handleRemoveTag(tag: string) { const ok = await removeTag(tag); if (ok) notify({ title: "Tag removed", message: `#${tag}`, tone: "danger" }); } async function handleConfirmDelete() { if (!toggleRemoveTags.length) return; await Promise.all(toggleRemoveTags.map(tag => removeTag(tag))); notify({ title: "Tags removed", message: toggleRemoveTags.join(", "), tone: "danger" }); setToggleRemoveTags([]); } async function handleToggleAllowMembers(value: boolean) { setLocalAllowMemberTagManage(value); const ok = await updateSettings(value, localJoinPolicy); if (ok) { notify({ title: "Tag permissions updated" }); return; } setLocalAllowMemberTagManage(settings.allowMemberTagManage); notify({ title: "Update failed", message: "Could not save tag permissions.", tone: "danger" }); } async function handleJoinPolicyChange(nextPolicy: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") { setLocalJoinPolicy(nextPolicy); const ok = await updateSettings(localAllowMemberTagManage, nextPolicy); if (ok) { notify({ title: "Join policy updated" }); return; } setLocalJoinPolicy(settings.joinPolicy); notify({ title: "Update failed", message: "Could not save join policy.", tone: "danger" }); } function handleOpenRenameModal() { setRenameValue(group?.name || ""); setRenameModalOpen(true); } function handleCloseRenameModal() { setRenameModalOpen(false); setRenameValue(group?.name || ""); } async function handleRenameGroup() { const name = renameValue.trim(); if (!name) return; const result = await groupsRename({ name }); if ("error" in result) { notify({ title: "Rename failed", message: result.error.message, tone: "danger" }); return; } notify({ title: "Group renamed", message: name }); setConfirmRenameOpen(false); setRenameModalOpen(false); } async function handleRoleChange(targetUserId: number, nextRole: "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER") { if (!isAdmin) return; const member = members.find(item => item.userId === targetUserId); if (!member) return; if (member.role === nextRole) return; if (nextRole === "GROUP_OWNER") { if (!isOwner) return; await transferOwner(targetUserId); return; } if (nextRole === "GROUP_ADMIN") { await promote(targetUserId); return; } await demote(targetUserId); } function getInviteLinkUrl(token: string) { if (typeof window === "undefined") return token; return `${window.location.origin}/invite/${token}`; } async function handleCopyInvite(token: string) { try { await navigator.clipboard.writeText(getInviteLinkUrl(token)); notify({ title: "Invite link copied", message: "Link copied to clipboard" }); } catch { notify({ title: "Copy failed", tone: "danger" }); } } function formatInviteExpiry(expiresAt: string) { const expiry = new Date(expiresAt).getTime(); if (Number.isNaN(expiry)) return "Unknown"; const diffMs = expiry - Date.now(); const absMs = Math.abs(diffMs); const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; const pick = (value: number, unit: string) => `${value} ${unit}${value === 1 ? "" : "s"}`; let label = ""; if (absMs < hour) { const value = Math.max(1, Math.round(absMs / minute)); label = pick(value, "minute"); } else if (absMs < day) { const value = Math.max(1, Math.round(absMs / hour)); label = pick(value, "hour"); } else { const value = Math.max(1, Math.round(absMs / day)); label = pick(value, "day"); } return diffMs >= 0 ? `in ${label}` : `${label} ago`; } function isInviteExpired(expiresAt: string) { const expiry = new Date(expiresAt).getTime(); if (Number.isNaN(expiry)) return false; return Date.now() > expiry; } function eventCategory(eventType: string) { const upper = eventType.toUpperCase(); if (upper.includes("SPENDING") || upper.includes("ENTRY")) return "entries" as const; if (upper.includes("TAG")) return "tags" as const; if (upper.includes("SETTING") || upper.includes("RENAMED")) return "settings" as const; return "members" as const; } const memberLookup = useMemo(() => { return new Map(members.map(member => [member.userId, { name: member.displayName || member.email, email: member.email }])); }, [members]); function getMemberLabel(userId?: number | null) { if (!userId) return "System"; const member = memberLookup.get(userId); return member?.name || `User ${userId}`; } const filteredEvents = events.filter(event => { const category = eventCategory(event.eventType); if (!auditFilters.includes(category)) return false; if (auditQuery) { const query = auditQuery.toLowerCase(); const actor = event.actorUserId ? memberLookup.get(event.actorUserId) : null; const haystack = `${event.eventType} ${event.actorUserId ?? ""} ${actor?.name || ""} ${actor?.email || ""} ${event.requestId}`.toLowerCase(); if (!haystack.includes(query)) return false; } if (auditFrom) { const from = new Date(auditFrom).getTime(); if (!Number.isNaN(from) && new Date(event.createdAt).getTime() < from) return false; } if (auditTo) { const to = new Date(auditTo).getTime(); if (!Number.isNaN(to) && new Date(event.createdAt).getTime() > to + 24 * 60 * 60 * 1000 - 1) return false; } return true; }); const todayKey = new Date().toISOString().slice(0, 10); const logsToday = events.filter(event => event.createdAt?.slice(0, 10) === todayKey).length; const mostActiveUser = (() => { const counts = new Map(); for (const event of events) { if (!event.actorUserId) continue; counts.set(event.actorUserId, (counts.get(event.actorUserId) ?? 0) + 1); } let top: number | null = null; let topCount = 0; counts.forEach((count, userId) => { if (count > topCount) { top = userId; topCount = count; } }); if (!top) return null; const member = memberLookup.get(top); const name = member?.name || `User ${top}`; const searchValue = member?.name || member?.email || String(top); return { userId: top, count: topCount, name, searchValue }; })(); const mostActiveCount = mostActiveUser?.count ?? 0; const renameDirty = Boolean(group && renameValue.trim() !== group.name); const memberCount = members.length; const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin; useEffect(() => { if (!renameModalOpen && !tagModalOpen) return; function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { if (tagModalOpen) setTagModalOpen(false); if (renameModalOpen) handleCloseRenameModal(); } if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) { if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]); async function handleDeleteGroup() { const result = await groupsDelete(); if ("error" in result) { notify({ title: "Delete failed", message: result.error.message, tone: "danger" }); return; } setConfirmDeleteGroupOpen(false); setDeleteConfirmText(""); notify({ title: "Group deleted", tone: "danger" }); router.push("/"); } if (!group) { return (
Loading group settings...
); } const visibleTags = showAllTags ? tags : tags.slice(0, 5); const hasMoreTags = tags.length > 5; const tagsScrollable = showAllTags && tags.length > 15; return ( <>

{group.name} settings

Manage tags and permissions for this group.

General Settings
Group name
{group.name}
{isAdmin ? ( ) : null}
{!isAdmin ? (
Only admins can rename the group.
) : null}
{isAdmin ? ( <>
Allow members to manage tags
) : null}
Group Tags ({tags.length})
{showAllTags ? ( ) : null} {canManageTags ? ( ) : null}
{visibleTags.map(tag => ( #{tag} ))} {!showAllTags && hasMoreTags ? ( ) : null} {!tags.length ?
No tags yet.
: null}
{isAdmin ? (
Join and Invites
Join policy
handleJoinPolicyChange(policy)} ariaLabel="Join policy" className="flex flex-wrap gap-2" buttonBaseClassName="rounded-lg border" sizeClassName="px-3 py-1.5 text-xs font-semibold transition" activeClassName="border-accent bg-accent-soft" inactiveClassName="border-accent-weak bg-panel hover:border-accent" options={[ { value: "NOT_ACCEPTING", label: "Disabled" }, { value: "AUTO_ACCEPT", label: "Auto" }, { value: "APPROVAL_REQUIRED", label: "Manual" } ]} />
Join Requests ({requests.length})
{!requests.length ? (
No pending requests.
) : (
{requests.map(request => (
{request.displayName || request.email}
{request.email}
))}
)}
Invite links
{!links.length ? (
No invite links yet.
) : (
{links.map(link => (
Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}
{(() => { const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt); const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt); const options = [ { value: "COPY", label: "Copy link", className: "btn-outline-accent", disabled: localJoinPolicy === "NOT_ACCEPTING", onClick: () => handleCopyInvite(link.token) }, ...(showRevive ? [{ value: "REVIVE", label: "Revive", className: "btn-outline-accent", disabled: localJoinPolicy === "NOT_ACCEPTING", onClick: () => reviveInvite(link.id, inviteTtlDays) }] : showRevoke ? [{ value: "REVOKE", label: "Revoke", className: "border border-red-400/60 bg-red-500/10 text-red-200", onClick: () => revokeInvite(link.id) }] : []), { value: "DELETE", label: "Delete", className: "border border-red-400/60 bg-red-500/10 text-red-200", onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token }) } ]; return ( ); })()}
Expires {formatInviteExpiry(link.expiresAt)} Uses: {link.singleUse ? "1 use" : "Unlimited"} Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : isInviteExpired(link.expiresAt) ? "Expired" : "Active"}
))}
)}
) : null}
Members
{memberCount} total
{members.map(member => { const isSelf = member.userId === currentUserId; const privilegeLabel = member.role === "GROUP_OWNER" ? "Owner - Full control" : member.role === "GROUP_ADMIN" ? "Admin - Manage members" : "Member - Entries only"; return (
{member.displayName || member.email} {isSelf ? You : null}
{member.email}
{privilegeLabel}
{isAdmin ? ( ) : ( {member.role} )} {isAdmin && member.role !== "GROUP_OWNER" ? ( ) : null} {isOwner && member.role !== "GROUP_OWNER" ? ( ) : null}
); })}
Danger zone
{showLeaveGroup ? ( ) : null}
{!isOwner ?
Only the owner can delete this group.
: null}
{canViewAudit ? (
Audit log
{auditOpen ? ( <>
Total logs
{events.length}
Logs today
{logsToday}
{([ { key: "entries", label: "Entries" }, { key: "members", label: "Members" }, { key: "tags", label: "Tags" }, { key: "settings", label: "Settings" } ] as const).map(filter => ( ))}
setAuditQuery(e.target.value)} />
setAuditFrom(e.target.value)} />
setAuditTo(e.target.value)} />
{!filteredEvents.length ? (
No audit events match your filters.
) : (
{filteredEvents.slice(0, auditLimit).map(event => (
{event.eventType}
Actor: {getMemberLabel(event.actorUserId)} Role: {event.actorRole ?? "-"} {new Date(event.createdAt).toLocaleString()}
))} {filteredEvents.length > auditLimit ? ( ) : null}
)} ) : (
Audit log is collapsed.
)}
) : null} {renameModalOpen ? (
event.stopPropagation()} onKeyDown={event => { if (event.key === "Escape") handleCloseRenameModal(); if (event.key === "Enter" && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true); }} role="dialog" tabIndex={-1} >
Change group name
setRenameValue(e.target.value)} placeholder="Group name" /> {renameDirty ? (
You have unsaved changes.
) : null}
{renameDirty ? ( <> ) : ( )}
) : null} {tagModalOpen ? (
setTagModalOpen(false)}>
event.stopPropagation()} onKeyDown={event => { if (event.key === "Escape") setTagModalOpen(false); // if (event.key === "Enter" && !event.shiftKey && pendingTags.length && canManageTags) handleSaveTags(); }} role="dialog" tabIndex={-1} >
Edit tags
setPendingTags(prev => prev.filter(item => item !== tag))} onAddTag={tag => setPendingTags(prev => prev.includes(tag) ? prev : [...prev, tag])} /> {!canManageTags ? (
Only admins can add new tags.
) : null}
Existing tags
{tags.map(tag => ( ))} {!tags.length ?
No tags yet.
: null}
{toggleRemoveTags.length ? ( ) : null}
) : null} setConfirmDeleteOpen(false)} onConfirm={() => { setConfirmDeleteOpen(false); handleConfirmDelete(); }} /> setConfirmDeleteInvite(null)} onConfirm={async () => { if (!confirmDeleteInvite) return; const target = confirmDeleteInvite; setConfirmDeleteInvite(null); const ok = await deleteInvite(target.id); if (ok) notify({ title: "Invite deleted", tone: "danger" }); }} /> setConfirmRenameOpen(false)} onConfirm={handleRenameGroup} /> setConfirmLeaveOpen(false)} onConfirm={async () => { setConfirmLeaveOpen(false); const ok = await leave(); if (ok) { notify({ title: "Left group" }); router.push("/"); } }} /> setConfirmKick(null)} onConfirm={async () => { if (!confirmKick) return; const target = confirmKick; setConfirmKick(null); const ok = await kick(target.userId); if (ok) notify({ title: "Member removed", message: target.name, tone: "danger" }); }} /> setConfirmTransfer(null)} onConfirm={async () => { if (!confirmTransfer) return; const target = confirmTransfer; setConfirmTransfer(null); const ok = await transferOwner(target.userId); if (ok) notify({ title: "Ownership transferred", message: target.name }); }} /> setConfirmDeleteGroupOpen(false)} onConfirm={handleDeleteGroup} />
); }