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 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]); 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 = async (memberId, username) => { if (!confirm(`Remove ${username} from this household?`)) return; try { await removeMember(activeHousehold.id, memberId); await loadMembers(); 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; return (
Household
Keep the household name crisp and easy to recognize across invites and shared lists.
Entry Rules
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
{inviteError}
}Loading invite settings...
) : pendingRequests.length === 0 ? (No pending join requests right now.
) : ({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()}
People
Role badges and compact actions make it easier to see who runs the household and who just shops.
Loading members...
) : (Final Actions
{isMemberOnly ? "Leaving removes your access to this household." : "Deleting a household is permanent and will delete all lists, items, and history."}