From af0d95432fbbd4db940b5f0d3d968f46ddf30445 Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 31 Mar 2026 00:32:40 -0700 Subject: [PATCH] feat: switch household joins to invite links --- frontend/src/api/households.js | 22 +- .../components/household/NoHouseholdState.jsx | 2 +- .../components/manage/CreateJoinHousehold.jsx | 77 ++++--- .../src/components/manage/ManageHousehold.jsx | 156 ++++++++------ .../components/manage/ManageHousehold.css | 164 +++++++++------ frontend/tests/invite-link-management.spec.ts | 194 ++++++++++++++++++ 6 files changed, 430 insertions(+), 185 deletions(-) create mode 100644 frontend/tests/invite-link-management.spec.ts diff --git a/frontend/src/api/households.js b/frontend/src/api/households.js index f435746..9ac78e4 100644 --- a/frontend/src/api/households.js +++ b/frontend/src/api/households.js @@ -27,18 +27,6 @@ export const updateHousehold = (householdId, name) => export const deleteHousehold = (householdId) => api.delete(`/households/${householdId}`); -/** - * Refresh household invite code - */ -export const refreshInviteCode = (householdId) => - api.post(`/households/${householdId}/invite/refresh`); - -/** - * Join a household using invite code - */ -export const joinHousehold = (inviteCode) => - api.post(`/households/join/${inviteCode}`); - /** * Get household members */ @@ -68,9 +56,19 @@ function groupHeaders(groupId) { export const getGroupInviteLinks = (groupId) => api.get("/api/groups/invites", groupHeaders(groupId)); +export const getPendingGroupJoinRequests = (groupId) => + api.get("/api/groups/join-requests", groupHeaders(groupId)); + export const createGroupInviteLink = (groupId, payload) => api.post("/api/groups/invites", payload, groupHeaders(groupId)); +export const decideGroupJoinRequest = (groupId, requestId, decision) => + api.post( + "/api/groups/join-requests/decision", + { requestId, decision }, + groupHeaders(groupId) + ); + export const revokeGroupInviteLink = (groupId, linkId) => api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId)); diff --git a/frontend/src/components/household/NoHouseholdState.jsx b/frontend/src/components/household/NoHouseholdState.jsx index 2a9c896..1bdb4bb 100644 --- a/frontend/src/components/household/NoHouseholdState.jsx +++ b/frontend/src/components/household/NoHouseholdState.jsx @@ -5,7 +5,7 @@ import CreateJoinHousehold from "../manage/CreateJoinHousehold"; export default function NoHouseholdState({ title = "No household yet", - description = "Create a household to start building lists, or join one with an invite code or invite link.", + description = "Create a household to start building lists, or join one with an invite link.", }) { const { error, refreshHouseholds } = useContext(HouseholdContext); const [showCreateJoin, setShowCreateJoin] = useState(false); diff --git a/frontend/src/components/manage/CreateJoinHousehold.jsx b/frontend/src/components/manage/CreateJoinHousehold.jsx index 275c152..a8bb221 100644 --- a/frontend/src/components/manage/CreateJoinHousehold.jsx +++ b/frontend/src/components/manage/CreateJoinHousehold.jsx @@ -1,18 +1,35 @@ import { useContext, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { joinHousehold } from "../../api/households"; import { HouseholdContext } from "../../context/HouseholdContext"; import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/manage/CreateJoinHousehold.css"; +function extractInviteToken(value) { + const trimmed = value.trim(); + if (!trimmed) return null; + + const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/); + if (directMatch) return directMatch[1]; + + try { + const parsed = new URL(trimmed, window.location.origin); + const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/); + if (urlMatch) return urlMatch[1]; + } catch { + return null; + } + + return null; +} + export default function CreateJoinHousehold({ initialMode = "create", onClose }) { const navigate = useNavigate(); const toast = useActionToast(); - const { createHousehold: createHouseholdWithContext, refreshHouseholds } = useContext(HouseholdContext); + const { createHousehold: createHouseholdWithContext } = useContext(HouseholdContext); const [mode, setMode] = useState(initialMode === "join" ? "join" : "create"); const [householdName, setHouseholdName] = useState(""); - const [inviteCode, setInviteCode] = useState(""); + const [inviteLink, setInviteLink] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -21,24 +38,6 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose }) setError(""); }, [initialMode]); - const extractInviteToken = (value) => { - const trimmed = value.trim(); - if (!trimmed) return null; - - const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/); - if (directMatch) return directMatch[1]; - - try { - const parsed = new URL(trimmed, window.location.origin); - const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/); - if (urlMatch) return urlMatch[1]; - } catch { - return null; - } - - return null; - }; - const handleCreate = async (e) => { e.preventDefault(); if (!householdName.trim()) return; @@ -62,29 +61,23 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose }) const handleJoin = async (e) => { e.preventDefault(); - if (!inviteCode.trim()) return; + if (!inviteLink.trim()) return; setLoading(true); setError(""); try { - const inviteToken = extractInviteToken(inviteCode); - if (inviteToken) { - toast.info("Invite link detected", "Opening invite details"); - onClose(); - navigate(`/invite/${inviteToken}`); + const inviteToken = extractInviteToken(inviteLink); + if (!inviteToken) { + const message = "Use a household invite link like /invite/abcd1234."; + setError(message); + toast.error("Open invite link failed", message); return; } - await joinHousehold(inviteCode); - await refreshHouseholds(); - toast.success("Joined household", "Joined household successfully"); + toast.info("Opening invite link", "Checking invite details"); onClose(); - } catch (err) { - console.error("Failed to join household:", err); - const message = getApiErrorMessage(err, "Failed to join household"); - setError(message); - toast.error("Join household failed", `Join household failed: ${message}`); + navigate(`/invite/${inviteToken}`); } finally { setLoading(false); } @@ -148,18 +141,18 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose }) ) : (
- + setInviteCode(e.target.value)} - placeholder="Invite code or /invite URL" + value={inviteLink} + onChange={(e) => setInviteLink(e.target.value)} + placeholder="https://.../invite/your-token" required autoFocus />

- Paste a raw invite code or full invite link URL + Paste the full invite URL or a local path like /invite/your-token

@@ -167,7 +160,7 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose }) Cancel
diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx index f0db3fe..94147c0 100644 --- a/frontend/src/components/manage/ManageHousehold.jsx +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -1,18 +1,19 @@ import React, { useContext, useEffect, useState } from "react"; import { createGroupInviteLink, + decideGroupJoinRequest, deleteGroupInviteLink, deleteHousehold, getGroupInviteLinks, getGroupJoinPolicy, getHouseholdMembers, - refreshInviteCode, + getPendingGroupJoinRequests, removeMember, revokeGroupInviteLink, reviveGroupInviteLink, setGroupJoinPolicy, updateHousehold, - updateMemberRole + updateMemberRole, } from "../../api/households"; import { ToggleButtonGroup } from "../common"; import ConfirmSlideModal from "../modals/ConfirmSlideModal"; @@ -42,6 +43,15 @@ const STATUS_METADATA = { 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); @@ -50,13 +60,14 @@ export default function ManageHousehold() { const [loading, setLoading] = useState(true); const [editingName, setEditingName] = useState(false); const [newName, setNewName] = useState(""); - const [showInviteCode, setShowInviteCode] = useState(false); 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 isManager = ["owner", "admin"].includes(activeHousehold?.role); @@ -66,6 +77,8 @@ export default function ManageHousehold() { loadMembers(); if (isManager) { loadJoinAndInvites(); + } else { + setPendingRequests([]); } }, [activeHousehold?.id, isManager]); @@ -87,12 +100,14 @@ export default function ManageHousehold() { setInviteLoading(true); setInviteError(""); try { - const [policyResponse, linksResponse] = await Promise.all([ + 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 { @@ -183,6 +198,27 @@ export default function ManageHousehold() { } }; + 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(""); @@ -240,19 +276,6 @@ export default function ManageHousehold() { } }; - const handleRefreshInvite = async () => { - if (!confirm("Generate a new invite code? The old code will no longer work.")) return; - - try { - await refreshInviteCode(activeHousehold.id); - await refreshHouseholds(); - toast.success("Generated new invite code", "Generated a new invite code"); - } catch (error) { - const message = getApiErrorMessage(error, "Failed to refresh invite code"); - toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`); - } - }; - const handleUpdateRole = async (memberId, currentRole, memberName) => { if (currentRole === "owner") return; const newRole = currentRole === "admin" ? "member" : "admin"; @@ -310,19 +333,6 @@ export default function ManageHousehold() { } }; - const copyInviteCode = async () => { - const copied = await copyTextToClipboard(activeHousehold.invite_code); - if (copied) { - toast.info("Copied invite code", "Copied invite code to clipboard"); - return; - } - - toast.error( - "Copy invite code failed", - "Copy invite code failed: unable to access clipboard. Copy manually." - ); - }; - const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; const memberCount = members.filter((member) => member.role === "member").length; @@ -375,47 +385,14 @@ export default function ManageHousehold() { )} - {isManager && ( -
-
-
-

Legacy Access

-

Invite Code

-

- Keep a simple code handy for older join flows while newer invite links stay fully managed below. -

-
-
-
-
-
- Current code - {showInviteCode ? activeHousehold.invite_code : "••••••••"} -
-
- - - -
-
-
-
- )} - {isManager && (

Entry Rules

-

Join and Invites

+

Invite Links

- Decide how new people can enter, then generate compact links that match your current policy. + Decide how new people can enter, review manual approvals, then create invite links for the flow you want.

@@ -427,11 +404,58 @@ export default function ManageHousehold() { className="tbg-group manage-household-join-policy-toggle" options={JOIN_POLICY_OPTIONS.map((option) => ({ ...option, - disabled: inviteLoading + disabled: inviteLoading, }))} onChange={handleUpdateJoinPolicy} /> +
+ Pending approvals + {pendingRequests.length} +
+ + {inviteLoading ? ( +

Loading invite settings...

+ ) : pendingRequests.length === 0 ? ( +

No pending join requests right now.

+ ) : ( +
+ {pendingRequests.map((request) => { + const requesterLabel = getRequesterLabel(request); + const isBusy = pendingDecisionId === request.id; + return ( +
+
+
+

{requesterLabel}

+ 🕒 Pending +
+

+ @{request.username} • Requested {new Date(request.created_at).toLocaleString()} +

+
+
+ + +
+
+ ); + })} +
+ )} +