405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { groupsDelete, groupsRename } from "@/lib/client/groups";
|
|
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
|
|
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
|
|
import useGroupMembers from "@/features/groups/hooks/use-group-members";
|
|
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
|
|
import useTags from "@/features/tags/hooks/use-tags";
|
|
import type {
|
|
AuditFilterKey,
|
|
ConfirmInviteDeleteTarget,
|
|
ConfirmUserTarget,
|
|
InviteMaxUses,
|
|
JoinPolicy
|
|
} from "@/features/groups/components/group-settings.types";
|
|
import { eventCategory, formatInviteExpiry, isInviteExpired } from "@/features/groups/components/group-settings.utils";
|
|
import { useGroupsContext } from "@/hooks/groups-context";
|
|
import { useNotificationsContext } from "@/hooks/notifications-context";
|
|
|
|
export default function useGroupSettingsViewModel(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<string[]>([]);
|
|
const [toggleRemoveTags, setToggleRemoveTags] = useState<string[]>([]);
|
|
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<ConfirmUserTarget | null>(null);
|
|
const [confirmTransfer, setConfirmTransfer] = useState<ConfirmUserTarget | 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<InviteMaxUses>("UNLIMITED");
|
|
const [auditOpen, setAuditOpen] = useState(false);
|
|
const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<ConfirmInviteDeleteTarget | null>(null);
|
|
const [auditFilters, setAuditFilters] = useState<AuditFilterKey[]>(["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<JoinPolicy>(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;
|
|
const memberCount = members.length;
|
|
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
|
|
|
|
useEffect(() => {
|
|
setRenameValue(group?.name || "");
|
|
}, [group?.name]);
|
|
|
|
useEffect(() => {
|
|
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
|
|
setLocalJoinPolicy(settings.joinPolicy);
|
|
}, [settings.allowMemberTagManage, settings.joinPolicy]);
|
|
|
|
const handleCloseRenameModal = useCallback(() => {
|
|
setRenameModalOpen(false);
|
|
setRenameValue(group?.name || "");
|
|
}, [group?.name]);
|
|
|
|
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 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: JoinPolicy) {
|
|
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);
|
|
}
|
|
|
|
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" });
|
|
}
|
|
}
|
|
|
|
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<number, number>();
|
|
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 visibleTags = showAllTags ? tags : tags.slice(0, 5);
|
|
const hasMoreTags = tags.length > 5;
|
|
const tagsScrollable = showAllTags && tags.length > 15;
|
|
|
|
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("/");
|
|
}
|
|
|
|
async function handleConfirmDeleteInvite() {
|
|
if (!confirmDeleteInvite) return;
|
|
const target = confirmDeleteInvite;
|
|
setConfirmDeleteInvite(null);
|
|
const ok = await deleteInvite(target.id);
|
|
if (ok) notify({ title: "Invite deleted", tone: "danger" });
|
|
}
|
|
|
|
async function handleConfirmLeaveGroup() {
|
|
setConfirmLeaveOpen(false);
|
|
const ok = await leave();
|
|
if (ok) {
|
|
notify({ title: "Left group" });
|
|
router.push("/");
|
|
}
|
|
}
|
|
|
|
async function handleConfirmKickMember() {
|
|
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" });
|
|
}
|
|
|
|
async function handleConfirmTransferOwnership() {
|
|
if (!confirmTransfer) return;
|
|
const target = confirmTransfer;
|
|
setConfirmTransfer(null);
|
|
const ok = await transferOwner(target.userId);
|
|
if (ok) notify({ title: "Ownership transferred", message: target.name });
|
|
}
|
|
|
|
return {
|
|
group,
|
|
role,
|
|
isAdmin,
|
|
isOwner,
|
|
canViewAudit,
|
|
canManageTags,
|
|
isLastAdmin,
|
|
memberCount,
|
|
showLeaveGroup,
|
|
tags,
|
|
members,
|
|
requests,
|
|
currentUserId,
|
|
links,
|
|
events,
|
|
filteredEvents,
|
|
logsToday,
|
|
mostActiveUser,
|
|
mostActiveCount,
|
|
visibleTags,
|
|
hasMoreTags,
|
|
tagsScrollable,
|
|
pendingTags,
|
|
setPendingTags,
|
|
toggleRemoveTags,
|
|
setToggleRemoveTags,
|
|
confirmDeleteOpen,
|
|
setConfirmDeleteOpen,
|
|
renameValue,
|
|
setRenameValue,
|
|
renameModalOpen,
|
|
setRenameModalOpen,
|
|
renameDirty,
|
|
confirmRenameOpen,
|
|
setConfirmRenameOpen,
|
|
confirmLeaveOpen,
|
|
setConfirmLeaveOpen,
|
|
confirmKick,
|
|
setConfirmKick,
|
|
confirmTransfer,
|
|
setConfirmTransfer,
|
|
confirmDeleteGroupOpen,
|
|
setConfirmDeleteGroupOpen,
|
|
deleteConfirmText,
|
|
setDeleteConfirmText,
|
|
tagModalOpen,
|
|
setTagModalOpen,
|
|
showAllTags,
|
|
setShowAllTags,
|
|
inviteTtlDays,
|
|
setInviteTtlDays,
|
|
inviteMaxUses,
|
|
setInviteMaxUses,
|
|
auditOpen,
|
|
setAuditOpen,
|
|
confirmDeleteInvite,
|
|
setConfirmDeleteInvite,
|
|
auditFilters,
|
|
setAuditFilters,
|
|
auditQuery,
|
|
setAuditQuery,
|
|
auditFrom,
|
|
setAuditFrom,
|
|
auditTo,
|
|
setAuditTo,
|
|
auditLimit,
|
|
setAuditLimit,
|
|
localAllowMemberTagManage,
|
|
localJoinPolicy,
|
|
handleSaveTags,
|
|
handleConfirmDelete,
|
|
handleToggleAllowMembers,
|
|
handleJoinPolicyChange,
|
|
handleOpenRenameModal,
|
|
handleCloseRenameModal,
|
|
handleRenameGroup,
|
|
handleRoleChange,
|
|
handleCopyInvite,
|
|
formatInviteExpiry,
|
|
isInviteExpired,
|
|
getMemberLabel,
|
|
handleDeleteGroup,
|
|
handleConfirmDeleteInvite,
|
|
handleConfirmLeaveGroup,
|
|
handleConfirmKickMember,
|
|
handleConfirmTransferOwnership,
|
|
approve,
|
|
deny,
|
|
promote,
|
|
demote,
|
|
kick,
|
|
transferOwner,
|
|
leave,
|
|
createInvite,
|
|
revokeInvite,
|
|
reviveInvite,
|
|
deleteInvite,
|
|
goBack: () => router.push("/")
|
|
};
|
|
}
|
|
|
|
export type GroupSettingsViewModelState = ReturnType<typeof useGroupSettingsViewModel>;
|