chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
6 changed files with 430 additions and 185 deletions
Showing only changes of commit af0d95432f - Show all commits

View File

@ -27,18 +27,6 @@ export const updateHousehold = (householdId, name) =>
export const deleteHousehold = (householdId) => export const deleteHousehold = (householdId) =>
api.delete(`/households/${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 * Get household members
*/ */
@ -68,9 +56,19 @@ function groupHeaders(groupId) {
export const getGroupInviteLinks = (groupId) => export const getGroupInviteLinks = (groupId) =>
api.get("/api/groups/invites", groupHeaders(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) => export const createGroupInviteLink = (groupId, payload) =>
api.post("/api/groups/invites", payload, groupHeaders(groupId)); 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) => export const revokeGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId)); api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));

View File

@ -5,7 +5,7 @@ import CreateJoinHousehold from "../manage/CreateJoinHousehold";
export default function NoHouseholdState({ export default function NoHouseholdState({
title = "No household yet", 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 { error, refreshHouseholds } = useContext(HouseholdContext);
const [showCreateJoin, setShowCreateJoin] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false);

View File

@ -1,27 +1,11 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { joinHousehold } from "../../api/households";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/CreateJoinHousehold.css"; import "../../styles/components/manage/CreateJoinHousehold.css";
export default function CreateJoinHousehold({ initialMode = "create", onClose }) { function extractInviteToken(value) {
const navigate = useNavigate();
const toast = useActionToast();
const { createHousehold: createHouseholdWithContext, refreshHouseholds } = useContext(HouseholdContext);
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
const [householdName, setHouseholdName] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setMode(initialMode === "join" ? "join" : "create");
setError("");
}, [initialMode]);
const extractInviteToken = (value) => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return null; if (!trimmed) return null;
@ -37,7 +21,22 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
} }
return null; return null;
}; }
export default function CreateJoinHousehold({ initialMode = "create", onClose }) {
const navigate = useNavigate();
const toast = useActionToast();
const { createHousehold: createHouseholdWithContext } = useContext(HouseholdContext);
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
const [householdName, setHouseholdName] = useState("");
const [inviteLink, setInviteLink] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setMode(initialMode === "join" ? "join" : "create");
setError("");
}, [initialMode]);
const handleCreate = async (e) => { const handleCreate = async (e) => {
e.preventDefault(); e.preventDefault();
@ -62,29 +61,23 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
const handleJoin = async (e) => { const handleJoin = async (e) => {
e.preventDefault(); e.preventDefault();
if (!inviteCode.trim()) return; if (!inviteLink.trim()) return;
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const inviteToken = extractInviteToken(inviteCode); const inviteToken = extractInviteToken(inviteLink);
if (inviteToken) { if (!inviteToken) {
toast.info("Invite link detected", "Opening invite details"); const message = "Use a household invite link like /invite/abcd1234.";
onClose(); setError(message);
navigate(`/invite/${inviteToken}`); toast.error("Open invite link failed", message);
return; return;
} }
await joinHousehold(inviteCode); toast.info("Opening invite link", "Checking invite details");
await refreshHouseholds();
toast.success("Joined household", "Joined household successfully");
onClose(); onClose();
} catch (err) { navigate(`/invite/${inviteToken}`);
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}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -148,18 +141,18 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
) : ( ) : (
<form onSubmit={handleJoin} className="household-form"> <form onSubmit={handleJoin} className="household-form">
<div className="form-group"> <div className="form-group">
<label htmlFor="inviteCode">Invite Code or Link</label> <label htmlFor="inviteLink">Invite Link</label>
<input <input
id="inviteCode" id="inviteLink"
type="text" type="text"
value={inviteCode} value={inviteLink}
onChange={(e) => setInviteCode(e.target.value)} onChange={(e) => setInviteLink(e.target.value)}
placeholder="Invite code or /invite URL" placeholder="https://.../invite/your-token"
required required
autoFocus autoFocus
/> />
<p className="form-hint"> <p className="form-hint">
Paste a raw invite code or full invite link URL Paste the full invite URL or a local path like /invite/your-token
</p> </p>
</div> </div>
<div className="form-actions"> <div className="form-actions">
@ -167,7 +160,7 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
Cancel Cancel
</button> </button>
<button type="submit" className="btn-primary" disabled={loading}> <button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Joining..." : "Join Household"} {loading ? "Opening..." : "Open Invite"}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,18 +1,19 @@
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import { import {
createGroupInviteLink, createGroupInviteLink,
decideGroupJoinRequest,
deleteGroupInviteLink, deleteGroupInviteLink,
deleteHousehold, deleteHousehold,
getGroupInviteLinks, getGroupInviteLinks,
getGroupJoinPolicy, getGroupJoinPolicy,
getHouseholdMembers, getHouseholdMembers,
refreshInviteCode, getPendingGroupJoinRequests,
removeMember, removeMember,
revokeGroupInviteLink, revokeGroupInviteLink,
reviveGroupInviteLink, reviveGroupInviteLink,
setGroupJoinPolicy, setGroupJoinPolicy,
updateHousehold, updateHousehold,
updateMemberRole updateMemberRole,
} from "../../api/households"; } from "../../api/households";
import { ToggleButtonGroup } from "../common"; import { ToggleButtonGroup } from "../common";
import ConfirmSlideModal from "../modals/ConfirmSlideModal"; import ConfirmSlideModal from "../modals/ConfirmSlideModal";
@ -42,6 +43,15 @@ const STATUS_METADATA = {
Expired: { tone: "expired", 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() { export default function ManageHousehold() {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext); const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
@ -50,13 +60,14 @@ export default function ManageHousehold() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [showInviteCode, setShowInviteCode] = useState(false);
const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING"); const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING");
const [inviteLinks, setInviteLinks] = useState([]); const [inviteLinks, setInviteLinks] = useState([]);
const [pendingRequests, setPendingRequests] = useState([]);
const [inviteLoading, setInviteLoading] = useState(false); const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState(""); const [inviteError, setInviteError] = useState("");
const [ttlDays, setTtlDays] = useState(7); const [ttlDays, setTtlDays] = useState(7);
const [singleUseMode, setSingleUseMode] = useState("UNLIMITED"); const [singleUseMode, setSingleUseMode] = useState("UNLIMITED");
const [pendingDecisionId, setPendingDecisionId] = useState(null);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isManager = ["owner", "admin"].includes(activeHousehold?.role);
@ -66,6 +77,8 @@ export default function ManageHousehold() {
loadMembers(); loadMembers();
if (isManager) { if (isManager) {
loadJoinAndInvites(); loadJoinAndInvites();
} else {
setPendingRequests([]);
} }
}, [activeHousehold?.id, isManager]); }, [activeHousehold?.id, isManager]);
@ -87,12 +100,14 @@ export default function ManageHousehold() {
setInviteLoading(true); setInviteLoading(true);
setInviteError(""); setInviteError("");
try { try {
const [policyResponse, linksResponse] = await Promise.all([ const [policyResponse, linksResponse, requestsResponse] = await Promise.all([
getGroupJoinPolicy(activeHousehold.id), getGroupJoinPolicy(activeHousehold.id),
getGroupInviteLinks(activeHousehold.id), getGroupInviteLinks(activeHousehold.id),
getPendingGroupJoinRequests(activeHousehold.id),
]); ]);
setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING"); setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING");
setInviteLinks(linksResponse.data.links || []); setInviteLinks(linksResponse.data.links || []);
setPendingRequests(requestsResponse.data.requests || []);
} catch (error) { } catch (error) {
setInviteError(error.response?.data?.error?.message || "Failed to load invite links"); setInviteError(error.response?.data?.error?.message || "Failed to load invite links");
} finally { } 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) => { const handleRevokeInvite = async (linkId) => {
try { try {
setInviteError(""); 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) => { const handleUpdateRole = async (memberId, currentRole, memberName) => {
if (currentRole === "owner") return; if (currentRole === "owner") return;
const newRole = currentRole === "admin" ? "member" : "admin"; 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 managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
const memberCount = members.filter((member) => member.role === "member").length; const memberCount = members.filter((member) => member.role === "member").length;
@ -375,47 +385,14 @@ export default function ManageHousehold() {
)} )}
</section> </section>
{isManager && (
<section key="invite-code" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Legacy Access</p>
<h2>Invite Code</h2>
<p className="section-description">
Keep a simple code handy for older join flows while newer invite links stay fully managed below.
</p>
</div>
</div>
<div className="invite-actions">
<div className="invite-code-panel">
<div className="invite-code-copy">
<span className="invite-code-label">Current code</span>
<code className="invite-code">{showInviteCode ? activeHousehold.invite_code : "••••••••"}</code>
</div>
<div className="invite-action-group">
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
{showInviteCode ? "Hide Code" : "Show Code"}
</button>
<button onClick={copyInviteCode} className="btn-secondary" disabled={!showInviteCode}>
Copy
</button>
<button onClick={handleRefreshInvite} className="btn-secondary">
Generate New Code
</button>
</div>
</div>
</div>
</section>
)}
{isManager && ( {isManager && (
<section key="join-and-invites" className="manage-section"> <section key="join-and-invites" className="manage-section">
<div className="manage-section-header"> <div className="manage-section-header">
<div> <div>
<p className="manage-section-eyebrow">Entry Rules</p> <p className="manage-section-eyebrow">Entry Rules</p>
<h2>Join and Invites</h2> <h2>Invite Links</h2>
<p className="section-description"> <p className="section-description">
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.
</p> </p>
</div> </div>
</div> </div>
@ -427,11 +404,58 @@ export default function ManageHousehold() {
className="tbg-group manage-household-join-policy-toggle" className="tbg-group manage-household-join-policy-toggle"
options={JOIN_POLICY_OPTIONS.map((option) => ({ options={JOIN_POLICY_OPTIONS.map((option) => ({
...option, ...option,
disabled: inviteLoading disabled: inviteLoading,
}))} }))}
onChange={handleUpdateJoinPolicy} 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"> <div className="invite-controls">
<label> <label>
<span className="invite-control-label">TTL</span> <span className="invite-control-label">TTL</span>

View File

@ -130,76 +130,115 @@ body.dark-mode .edit-name-form input {
border-color: var(--color-border-medium); border-color: var(--color-border-medium);
} }
/* Invite Code Section */ .manage-household-join-policy-toggle {
.invite-actions { margin-bottom: 0.2rem;
display: flex;
flex-direction: column;
} }
.invite-code-panel { .pending-requests-summary {
display: grid; display: inline-flex;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.9rem;
align-items: center; align-items: center;
padding: 0.95rem 1rem; gap: 0.65rem;
border: 1px solid var(--color-border-light); width: fit-content;
border-radius: var(--border-radius-lg); padding: 0.45rem 0.8rem;
background: rgba(255, 255, 255, 1); border-radius: var(--border-radius-full);
background: rgba(30, 144, 255, 0.1);
color: var(--primary-dark);
} }
[data-theme="dark"] .invite-code-panel, .pending-requests-summary-label {
body.dark-mode .invite-code-panel {
background: rgba(12, 19, 30, 0.9);
border-color: var(--color-border-medium);
}
.invite-code-copy {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.invite-code-label {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.04em;
color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
} }
.invite-code { .pending-requests-summary-count {
min-width: 1.85rem;
height: 1.85rem;
display: inline-flex; display: inline-flex;
width: fit-content; align-items: center;
max-width: 100%; justify-content: center;
overflow: hidden; border-radius: 999px;
text-overflow: ellipsis; background: var(--primary);
background: var(--background); color: var(--color-text-inverse);
padding: 0.65rem 0.8rem; font-size: 0.85rem;
border-radius: var(--border-radius-md);
font-family: var(--font-family-mono);
font-size: 0.95rem;
color: var(--primary);
border: 1px solid var(--border);
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em;
} }
[data-theme="dark"] .invite-code, [data-theme="dark"] .pending-requests-summary,
body.dark-mode .invite-code { body.dark-mode .pending-requests-summary {
background: rgba(8, 14, 24, 0.95); background: rgba(95, 178, 255, 0.14);
color: #d8ecff;
}
.pending-requests-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pending-request-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: center;
padding: 0.95rem 1rem;
border: 1px solid var(--border);
border-radius: var(--border-radius-lg);
background: color-mix(in srgb, var(--primary-light) 40%, white);
}
[data-theme="dark"] .pending-request-card,
body.dark-mode .pending-request-card {
background: rgba(14, 27, 45, 0.96);
border-color: var(--color-border-medium); border-color: var(--color-border-medium);
} }
.invite-action-group { .pending-request-main {
display: flex; min-width: 0;
flex-wrap: wrap;
gap: 0.55rem;
justify-content: flex-end;
} }
.manage-household-join-policy-toggle { .pending-request-topline {
margin-bottom: 0.2rem; display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.35rem;
}
.pending-request-name,
.pending-request-meta {
margin: 0;
}
.pending-request-name {
font-weight: 700;
color: var(--text-primary);
}
.pending-request-meta {
color: var(--text-secondary);
font-size: 0.84rem;
line-height: 1.5;
}
.pending-request-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.6rem;
border-radius: var(--border-radius-full);
background: rgba(245, 158, 11, 0.18);
color: #b45309;
font-size: 0.78rem;
font-weight: 700;
}
.pending-request-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
} }
.invite-controls { .invite-controls {
@ -544,6 +583,14 @@ body.dark-mode .danger-zone {
.invite-link-actions { .invite-link-actions {
justify-content: flex-start; justify-content: flex-start;
} }
.pending-request-card {
grid-template-columns: 1fr;
}
.pending-request-actions {
justify-content: flex-start;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@ -562,18 +609,6 @@ body.dark-mode .danger-zone {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.invite-code-panel {
grid-template-columns: 1fr;
}
.invite-action-group {
justify-content: stretch;
}
.invite-action-group button {
flex: 1 1 100%;
}
.invite-controls { .invite-controls {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -596,7 +631,8 @@ body.dark-mode .danger-zone {
width: 100%; width: 100%;
} }
.member-actions button { .member-actions button,
.pending-request-actions button {
flex: 1 1 100%; flex: 1 1 100%;
} }
} }

View File

@ -0,0 +1,194 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page, role = "admin") {
return page.addInitScript((seedRole) => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", seedRole);
localStorage.setItem("username", "manager-user");
}, role);
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
test("join household modal accepts invite links but rejects legacy invite codes", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/invite-links/approval-token", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
link: {
token: "approval-token",
status: "ACTIVE",
viewerStatus: null,
active_policy: "APPROVAL_REQUIRED",
group_name: "Approval Home",
},
}),
});
});
await page.goto("/manage");
await page.getByRole("button", { name: "Join Household" }).click();
await page.getByLabel("Invite Link").fill("HABC123");
await page.getByRole("button", { name: "Open Invite" }).click();
await expect(page.getByText("Use a household invite link like /invite/abcd1234.")).toBeVisible();
await page.getByLabel("Invite Link").fill("/invite/approval-token");
await page.getByRole("button", { name: "Open Invite" }).click();
await expect(page).toHaveURL(/\/invite\/approval-token$/);
await expect(page.getByRole("heading", { name: "Join Approval Home" })).toBeVisible();
});
test("household management shows pending invite approvals and can approve them", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
let members = [
{ id: 1, username: "manager-user", role: "owner" },
];
let pendingRequests = [
{
id: 41,
user_id: 7,
username: "pending-pal",
name: "Pending Pal",
display_name: "",
created_at: "2026-03-31T12:00:00.000Z",
status: "PENDING",
},
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Approval Home", role: "owner", invite_code: "ABCD1234" }]),
});
});
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/api/groups/join-policy", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ joinPolicy: "APPROVAL_REQUIRED" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/groups/invites", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
links: [
{
id: 9,
token: "invite-token-1234",
policy: "APPROVAL_REQUIRED",
single_use: false,
expires_at: "2030-01-01T00:00:00.000Z",
used_at: null,
revoked_at: null,
},
],
}),
});
return;
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ link: { id: 10, token: "new-token" } }),
});
});
await page.route("**/api/groups/join-requests", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ requests: pendingRequests }),
});
});
await page.route("**/api/groups/join-requests/decision", async (route) => {
const body = route.request().postDataJSON() as { requestId?: number; decision?: string };
if (body.requestId === 41 && body.decision === "APPROVE") {
pendingRequests = [];
members = [
...members,
{ id: 7, username: "pending-pal", role: "member" },
];
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
request: {
id: 41,
user_id: 7,
status: body.decision === "APPROVE" ? "APPROVED" : "DENIED",
},
}),
});
});
await page.goto("/manage?tab=household");
await expect(page.getByRole("heading", { name: "Invite Links" })).toBeVisible();
await expect(page.getByText("Pending Pal")).toBeVisible();
await expect(page.getByText("Invite Code")).toHaveCount(0);
await page.getByRole("button", { name: "Approve" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
await expect(page.getByText("No pending join requests right now.")).toBeVisible();
await expect(page.getByText("Members (2)")).toBeVisible();
});