import React, { useContext, useEffect, useState } from "react"; import { createGroupInviteLink, decideGroupJoinRequest, deleteGroupInviteLink, deleteHousehold, getGroupInviteLinks, getGroupJoinPolicy, getHouseholdMembers, getPendingGroupJoinRequests, removeMember, revokeGroupInviteLink, reviveGroupInviteLink, setGroupJoinPolicy, updateHousehold, updateMemberRole, } from "../../api/households"; import { ToggleButtonGroup } from "../common"; import ConfirmSlideModal from "../modals/ConfirmSlideModal"; import { AuthContext } from "../../context/AuthContext"; import { HouseholdContext } from "../../context/HouseholdContext"; import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/manage/ManageHousehold.css"; const JOIN_POLICY_OPTIONS = [ { label: "Disabled", value: "NOT_ACCEPTING" }, { label: "Auto", value: "AUTO_ACCEPT" }, { label: "Manual", value: "APPROVAL_REQUIRED" }, ]; const ROLE_METADATA = { owner: { icon: "π", label: "Owner" }, admin: { icon: "π οΈ", label: "Admin" }, member: { icon: "π", label: "Member" }, viewer: { icon: "π", label: "Viewer" }, }; const STATUS_METADATA = { Active: { tone: "active", icon: "π’" }, Used: { tone: "used", icon: "βͺ" }, Revoked: { tone: "revoked", icon: "π΄" }, Expired: { tone: "expired", icon: "π " }, }; function getRequesterLabel(request) { return ( request.display_name?.trim() || request.name?.trim() || request.username?.trim() || `User #${request.user_id}` ); } export default function ManageHousehold() { const { userId } = useContext(AuthContext); const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext); const toast = useActionToast(); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [editingName, setEditingName] = useState(false); const [newName, setNewName] = useState(""); const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING"); const [inviteLinks, setInviteLinks] = useState([]); const [pendingRequests, setPendingRequests] = useState([]); const [inviteLoading, setInviteLoading] = useState(false); const [inviteError, setInviteError] = useState(""); const [ttlDays, setTtlDays] = useState(7); const [singleUseMode, setSingleUseMode] = useState("UNLIMITED"); const [pendingDecisionId, setPendingDecisionId] = useState(null); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [pendingRoleChange, setPendingRoleChange] = useState(null); const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isOwner = activeHousehold?.role === "owner"; const isMemberOnly = activeHousehold?.role === "member"; useEffect(() => { loadMembers(); if (isManager) { loadJoinAndInvites(); } else { setPendingRequests([]); } }, [activeHousehold?.id, isManager]); useEffect(() => { if (!selectedMember) return undefined; const handleKeyDown = (event) => { if (event.key === "Escape") { setSelectedMember(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedMember]); const loadMembers = async () => { if (!activeHousehold?.id) return; setLoading(true); try { const response = await getHouseholdMembers(activeHousehold.id); setMembers(response.data); } catch (error) { console.error("Failed to load members:", error); } finally { setLoading(false); } }; const loadJoinAndInvites = async () => { if (!activeHousehold?.id || !isManager) return; setInviteLoading(true); setInviteError(""); try { const [policyResponse, linksResponse, requestsResponse] = await Promise.all([ getGroupJoinPolicy(activeHousehold.id), getGroupInviteLinks(activeHousehold.id), getPendingGroupJoinRequests(activeHousehold.id), ]); setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING"); setInviteLinks(linksResponse.data.links || []); setPendingRequests(requestsResponse.data.requests || []); } catch (error) { setInviteError(error.response?.data?.error?.message || "Failed to load invite links"); } finally { setInviteLoading(false); } }; const getLinkStatus = (link) => { const now = Date.now(); if (link.single_use && link.used_at) return "Used"; if (link.revoked_at) return "Revoked"; if (new Date(link.expires_at).getTime() <= now) return "Expired"; return "Active"; }; const copyTextToClipboard = async (text) => { if (!text) return false; if (navigator?.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return true; } catch { // Fall through to legacy copy fallback. } } try { const textArea = document.createElement("textarea"); textArea.value = text; textArea.setAttribute("readonly", ""); textArea.style.position = "fixed"; textArea.style.top = "-9999px"; textArea.style.left = "-9999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const copied = document.execCommand("copy"); document.body.removeChild(textArea); return copied; } catch { return false; } }; const copyInviteLink = async (token) => { const inviteUrl = `${window.location.origin}/invite/${encodeURIComponent(token)}`; const copied = await copyTextToClipboard(inviteUrl); if (copied) { const tokenLast4 = String(token || "").slice(-4); toast.info("Copied invite link", `Copied invite link ending in ${tokenLast4}`); return; } toast.error( "Copy invite link failed", "Copy invite link failed: unable to access clipboard. Copy manually." ); }; const handleCreateInviteLink = async () => { try { setInviteError(""); await createGroupInviteLink(activeHousehold.id, { policy: joinPolicy, singleUse: singleUseMode === "ONE_TIME", ttlDays, }); await loadJoinAndInvites(); toast.success("Created invite link", `Created invite link (${ttlDays} day TTL)`); } catch (error) { const message = getApiErrorMessage(error, "Failed to create invite link"); setInviteError(message); toast.error("Create invite link failed", `Create invite link failed: ${message}`); } }; const handleUpdateJoinPolicy = async (value) => { try { setInviteError(""); await setGroupJoinPolicy(activeHousehold.id, value); setJoinPolicyValue(value); toast.success("Updated join policy", `Join policy set to ${value}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to update join policy"); setInviteError(message); toast.error("Update join policy failed", `Update join policy failed: ${message}`); } }; const handleJoinRequestDecision = async (request, decision) => { const requesterName = getRequesterLabel(request); setPendingDecisionId(request.id); try { setInviteError(""); await decideGroupJoinRequest(activeHousehold.id, request.id, decision); await Promise.all([loadJoinAndInvites(), loadMembers()]); if (decision === "APPROVE") { toast.success("Approved join request", `Approved ${requesterName}`); } else { toast.info("Denied join request", `Denied ${requesterName}`); } } catch (error) { const message = getApiErrorMessage(error, "Failed to update join request"); setInviteError(message); toast.error("Join request update failed", `Join request update failed: ${message}`); } finally { setPendingDecisionId(null); } }; const handleRevokeInvite = async (linkId) => { try { setInviteError(""); await revokeGroupInviteLink(activeHousehold.id, linkId); await loadJoinAndInvites(); toast.success("Revoked invite link", "Revoked invite link"); } catch (error) { const message = getApiErrorMessage(error, "Failed to revoke invite link"); setInviteError(message); toast.error("Revoke invite link failed", `Revoke invite link failed: ${message}`); } }; const handleReviveInvite = async (linkId) => { try { setInviteError(""); await reviveGroupInviteLink(activeHousehold.id, linkId, ttlDays); await loadJoinAndInvites(); toast.success("Revived invite link", `Revived invite link for ${ttlDays} days`); } catch (error) { const message = getApiErrorMessage(error, "Failed to revive invite link"); setInviteError(message); toast.error("Revive invite link failed", `Revive invite link failed: ${message}`); } }; const handleDeleteInvite = async (linkId) => { if (!confirm("Delete this invite link permanently?")) return; try { setInviteError(""); await deleteGroupInviteLink(activeHousehold.id, linkId); await loadJoinAndInvites(); toast.success("Deleted invite link", "Deleted invite link"); } catch (error) { const message = getApiErrorMessage(error, "Failed to delete invite link"); setInviteError(message); toast.error("Delete invite link failed", `Delete invite link failed: ${message}`); } }; const handleUpdateName = async () => { if (!newName.trim() || newName === activeHousehold.name) { setEditingName(false); return; } try { await updateHousehold(activeHousehold.id, newName); await refreshHouseholds(); toast.success("Updated household name", `Updated household name to ${newName.trim()}`); setEditingName(false); } catch (error) { const message = getApiErrorMessage(error, "Failed to update household name"); toast.error("Update household name failed", `Update household name failed: ${message}`); } }; const handleConfirmRoleChange = async () => { if (!pendingRoleChange) return; const { memberId, nextRole, memberName } = pendingRoleChange; try { await updateMemberRole(activeHousehold.id, memberId, nextRole); await Promise.all([ loadMembers(), nextRole === "owner" ? refreshHouseholds() : Promise.resolve(), ]); if (nextRole === "owner") { toast.success("Transferred household ownership", `Transferred ownership to ${memberName}`); } else { toast.success("Updated member role", `Updated role for ${memberName} to ${nextRole}`); } setPendingRoleChange(null); } catch (error) { const message = getApiErrorMessage(error, "Failed to update member role"); toast.error("Update member role failed", `Update member role failed: ${message}`); } }; const handleUpdateRole = (memberId, nextRole, memberName) => { if (!nextRole) return; setPendingRoleChange({ memberId, nextRole, memberName }); }; const handleRemoveMember = (memberId, username) => { setPendingMemberRemoval({ memberId, username }); }; const handleConfirmRemoveMember = async () => { if (!pendingMemberRemoval) return; const { memberId, username } = pendingMemberRemoval; try { await removeMember(activeHousehold.id, memberId); await loadMembers(); setPendingMemberRemoval(null); toast.success("Removed member", `Removed member ${username}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to remove member"); toast.error("Remove member failed", `Remove member failed: ${message}`); } }; const handleDeleteHousehold = async () => { if (!confirm(`Delete "${activeHousehold.name}"? This will delete all lists and data. This cannot be undone.`)) return; if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return; try { const householdName = activeHousehold.name; await deleteHousehold(activeHousehold.id); await refreshHouseholds(); toast.success("Deleted household", `Deleted household ${householdName}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to delete household"); toast.error("Delete household failed", `Delete household failed: ${message}`); } }; const handleLeaveHousehold = async () => { if (!activeHousehold?.id) return; try { const householdName = activeHousehold.name; await removeMember(activeHousehold.id, parseInt(userId, 10)); setIsLeaveModalOpen(false); await refreshHouseholds(); toast.success("Left household", `Left household ${householdName}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to leave household"); toast.error("Leave household failed", `Leave household failed: ${message}`); } }; const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; const memberCount = members.filter((member) => member.role === "member").length; const selectedRoleMeta = selectedMember ? ROLE_METADATA[selectedMember.role] || { icon: "Γ°ΕΈβΒ€", label: selectedMember.role } : null; const selectedMemberIsSelf = selectedMember?.id === parseInt(userId, 10); const canManageSelectedMember = Boolean(selectedMember) && isManager && !selectedMemberIsSelf && selectedMember.role !== "owner"; const selectedMemberNextRole = selectedMember?.role === "admin" ? "member" : "admin"; const openMemberRoleChange = (nextRole) => { if (!selectedMember) return; handleUpdateRole(selectedMember.id, nextRole, selectedMember.username); setSelectedMember(null); }; const openMemberRemoval = () => { if (!selectedMember) return; handleRemoveMember(selectedMember.id, selectedMember.username); setSelectedMember(null); }; return (
{inviteError}
}Loading invite settings...
) : pendingRequests.length > 0 ? ({requesterLabel}
π Pending@{request.username} β’ Requested {new Date(request.created_at).toLocaleString()}
Loading invite links...
) : inviteLinks.length === 0 ? (No invite links yet.
) : (Invite ending in {String(link.token).slice(-4)}
{statusMeta.icon} {status}Policy: {link.policy} β’ Expires {new Date(link.expires_at).toLocaleString()}
Loading members...
) : (No actions available for this member.
)}