grocery-app/frontend/src/components/manage/ManageHousehold.jsx
Nico 5a2848ebcf
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 11s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 1s
Build & Deploy Costco Grocery List / deploy (push) Successful in 5s
Build & Deploy Costco Grocery List / notify (push) Successful in 0s
refactor: use slide confirmation for role changes
2026-03-31 01:24:22 -07:00

670 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="manage-household">
<section key="household-name" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2>
<p className="section-description">
Keep the household name crisp and easy to recognize across invites and shared lists.
</p>
</div>
</div>
{editingName ? (
<div className="edit-name-form">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Household name"
autoFocus
/>
<button onClick={handleUpdateName} className="btn-primary">Save</button>
<button onClick={() => setEditingName(false)} className="btn-secondary">Cancel</button>
</div>
) : (
<div className="name-display">
<div className="name-display-copy">
<h3>{activeHousehold.name}</h3>
<div className="household-summary-chips">
<span className="household-summary-chip">🏠 {members.length} people</span>
<span className="household-summary-chip">🛡 {managerCount} managers</span>
<span className="household-summary-chip">🛒 {memberCount} shoppers</span>
</div>
</div>
{isManager && (
<button
onClick={() => {
setNewName(activeHousehold.name);
setEditingName(true);
}}
className="btn-secondary"
>
Edit Name
</button>
)}
</div>
)}
</section>
{isManager && (
<section key="join-and-invites" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2>
<p className="section-description">
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
</p>
</div>
</div>
{inviteError && <p className="section-error">{inviteError}</p>}
<ToggleButtonGroup
value={joinPolicy}
ariaLabel="Join policy options"
className="tbg-group manage-household-join-policy-toggle"
options={JOIN_POLICY_OPTIONS.map((option) => ({
...option,
disabled: inviteLoading,
}))}
onChange={handleUpdateJoinPolicy}
/>
<div className="pending-requests-summary">
<span className="pending-requests-summary-label">Pending approvals</span>
<span className="pending-requests-summary-count">{pendingRequests.length}</span>
</div>
{inviteLoading ? (
<p>Loading invite settings...</p>
) : pendingRequests.length === 0 ? (
<p className="section-description">No pending join requests right now.</p>
) : (
<div className="pending-requests-list">
{pendingRequests.map((request) => {
const requesterLabel = getRequesterLabel(request);
const isBusy = pendingDecisionId === request.id;
return (
<div key={request.id} className="pending-request-card">
<div className="pending-request-main">
<div className="pending-request-topline">
<p className="pending-request-name">{requesterLabel}</p>
<span className="pending-request-badge">🕒 Pending</span>
</div>
<p className="pending-request-meta">
@{request.username} Requested {new Date(request.created_at).toLocaleString()}
</p>
</div>
<div className="pending-request-actions">
<button
className="btn-secondary btn-small member-role-action"
onClick={() => handleJoinRequestDecision(request, "APPROVE")}
disabled={isBusy}
>
{isBusy ? "Working..." : "Approve"}
</button>
<button
className="btn-danger btn-small"
onClick={() => handleJoinRequestDecision(request, "DENY")}
disabled={isBusy}
>
Deny
</button>
</div>
</div>
);
})}
</div>
)}
<div className="invite-controls">
<label>
<span className="invite-control-label">TTL</span>
<select value={ttlDays} onChange={(e) => setTtlDays(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7].map((day) => (
<option key={day} value={day}>{day} day{day > 1 ? "s" : ""}</option>
))}
</select>
</label>
<label>
<span className="invite-control-label">Usage</span>
<select value={singleUseMode} onChange={(e) => setSingleUseMode(e.target.value)}>
<option value="UNLIMITED">Unlimited</option>
<option value="ONE_TIME">1 use</option>
</select>
</label>
<button className="btn-primary" onClick={handleCreateInviteLink} disabled={inviteLoading}>
Create link
</button>
</div>
{inviteLoading ? (
<p>Loading invite links...</p>
) : inviteLinks.length === 0 ? (
<p className="section-description">No invite links yet.</p>
) : (
<div className="invite-links-list">
{inviteLinks.map((link) => {
const status = getLinkStatus(link);
const isActive = status === "Active";
const statusMeta = STATUS_METADATA[status] || STATUS_METADATA.Active;
return (
<div key={link.id} className="invite-link-card">
<div className="invite-link-main">
<div className="invite-link-topline">
<p className="invite-link-token">Invite ending in {String(link.token).slice(-4)}</p>
<span className={`invite-status-badge is-${statusMeta.tone}`}>
{statusMeta.icon} {status}
</span>
</div>
<p className="invite-link-meta">
Policy: {link.policy} Expires {new Date(link.expires_at).toLocaleString()}
</p>
</div>
<div className="invite-link-actions">
<button className="btn-secondary btn-small" onClick={() => copyInviteLink(link.token)}>
Copy link
</button>
{isActive ? (
<button className="btn-secondary btn-small" onClick={() => handleRevokeInvite(link.id)}>
Revoke
</button>
) : (
<button className="btn-secondary btn-small" onClick={() => handleReviveInvite(link.id)}>
Revive
</button>
)}
<button className="btn-danger btn-small" onClick={() => handleDeleteInvite(link.id)}>
Delete
</button>
</div>
</div>
);
})}
</div>
)}
</section>
)}
<section key="members" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2>
<p className="section-description">
Role badges and compact actions make it easier to see who runs the household and who just shops.
</p>
</div>
</div>
{loading ? (
<p>Loading members...</p>
) : (
<div className="members-list">
{members.map((member) => {
const roleMeta = ROLE_METADATA[member.role] || { icon: "👤", label: member.role };
const isSelf = member.id === parseInt(userId, 10);
return (
<div key={member.id} className="member-card">
<div className="member-main">
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
<div className="member-info">
<div className="member-topline">
<span className={`member-role member-role-${member.role}`}>
{roleMeta.icon} {roleMeta.label}
</span>
{isSelf && <span className="member-self-pill"> You</span>}
</div>
<span className="member-name">{member.username}</span>
<span className="member-meta">ID #{member.id}</span>
</div>
</div>
{isManager && !isSelf && member.role !== "owner" && (
<div className="member-actions">
{isOwner && (
<button
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
className="btn-primary btn-small member-owner-action"
>
Make Owner
</button>
)}
<button
onClick={() => handleUpdateRole(
member.id,
member.role === "admin" ? "member" : "admin",
member.username
)}
className="btn-secondary btn-small member-role-action"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.id, member.username)}
className="btn-danger btn-small"
>
Remove
</button>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{(isManager || isMemberOnly) && (
<section key="danger-zone" className="manage-section danger-zone">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2>
<p className="section-description">
{isMemberOnly
? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."}
</p>
</div>
{isMemberOnly ? (
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
Leave Household
</button>
) : (
<button onClick={handleDeleteHousehold} className="btn-danger">
Delete Household
</button>
)}
</div>
</section>
)}
<ConfirmSlideModal
isOpen={isLeaveModalOpen}
title={`Leave "${activeHousehold?.name || "this household"}"?`}
description="Slide all the way to confirm. You will lose access to this household."
confirmLabel="Leave Household"
onClose={() => setIsLeaveModalOpen(false)}
onConfirm={handleLeaveHousehold}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingRoleChange)}
title={
pendingRoleChange?.nextRole === "owner"
? `Transfer ownership to ${pendingRoleChange?.memberName || "this member"}?`
: `Change ${pendingRoleChange?.memberName || "this member"} to ${pendingRoleChange?.nextRole || "member"}?`
}
description={
pendingRoleChange?.nextRole === "owner"
? "Slide to confirm. They will become the household owner and you will become an admin."
: "Slide to confirm this household role change."
}
confirmLabel={
pendingRoleChange?.nextRole === "owner"
? "Transfer Ownership"
: `Make ${pendingRoleChange?.nextRole === "admin" ? "Admin" : "Member"}`
}
onClose={() => setPendingRoleChange(null)}
onConfirm={handleConfirmRoleChange}
/>
</div>
);
}