feat: switch household joins to invite links

This commit is contained in:
Nico 2026-03-31 00:32:40 -07:00
parent 9bdf2247f4
commit af0d95432f
6 changed files with 430 additions and 185 deletions

View File

@ -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));

View File

@ -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);

View File

@ -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 })
) : (
<form onSubmit={handleJoin} className="household-form">
<div className="form-group">
<label htmlFor="inviteCode">Invite Code or Link</label>
<label htmlFor="inviteLink">Invite Link</label>
<input
id="inviteCode"
id="inviteLink"
type="text"
value={inviteCode}
onChange={(e) => 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
/>
<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>
</div>
<div className="form-actions">
@ -167,7 +160,7 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
Cancel
</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Joining..." : "Join Household"}
{loading ? "Opening..." : "Open Invite"}
</button>
</div>
</form>

View File

@ -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() {
)}
</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 && (
<section key="join-and-invites" className="manage-section">
<div className="manage-section-header">
<div>
<p className="manage-section-eyebrow">Entry Rules</p>
<h2>Join and Invites</h2>
<h2>Invite Links</h2>
<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>
</div>
</div>
@ -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}
/>
<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>

View File

@ -130,76 +130,115 @@ body.dark-mode .edit-name-form input {
border-color: var(--color-border-medium);
}
/* Invite Code Section */
.invite-actions {
display: flex;
flex-direction: column;
.manage-household-join-policy-toggle {
margin-bottom: 0.2rem;
}
.invite-code-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.9rem;
.pending-requests-summary {
display: inline-flex;
align-items: center;
padding: 0.95rem 1rem;
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-lg);
background: rgba(255, 255, 255, 1);
gap: 0.65rem;
width: fit-content;
padding: 0.45rem 0.8rem;
border-radius: var(--border-radius-full);
background: rgba(30, 144, 255, 0.1);
color: var(--primary-dark);
}
[data-theme="dark"] .invite-code-panel,
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 {
.pending-requests-summary-label {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--text-secondary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.invite-code {
.pending-requests-summary-count {
min-width: 1.85rem;
height: 1.85rem;
display: inline-flex;
width: fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
background: var(--background);
padding: 0.65rem 0.8rem;
border-radius: var(--border-radius-md);
font-family: var(--font-family-mono);
font-size: 0.95rem;
color: var(--primary);
border: 1px solid var(--border);
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--primary);
color: var(--color-text-inverse);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
}
[data-theme="dark"] .invite-code,
body.dark-mode .invite-code {
background: rgba(8, 14, 24, 0.95);
[data-theme="dark"] .pending-requests-summary,
body.dark-mode .pending-requests-summary {
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);
}
.invite-action-group {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
justify-content: flex-end;
.pending-request-main {
min-width: 0;
}
.manage-household-join-policy-toggle {
margin-bottom: 0.2rem;
.pending-request-topline {
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 {
@ -544,6 +583,14 @@ body.dark-mode .danger-zone {
.invite-link-actions {
justify-content: flex-start;
}
.pending-request-card {
grid-template-columns: 1fr;
}
.pending-request-actions {
justify-content: flex-start;
}
}
@media (max-width: 768px) {
@ -562,18 +609,6 @@ body.dark-mode .danger-zone {
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 {
grid-template-columns: 1fr;
}
@ -596,7 +631,8 @@ body.dark-mode .danger-zone {
width: 100%;
}
.member-actions button {
.member-actions button,
.pending-request-actions button {
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();
});