chore: harden reliability checks #2
@ -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));
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -1,18 +1,35 @@
|
|||||||
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";
|
||||||
|
|
||||||
|
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 }) {
|
export default function CreateJoinHousehold({ initialMode = "create", onClose }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const { createHousehold: createHouseholdWithContext, refreshHouseholds } = useContext(HouseholdContext);
|
const { createHousehold: createHouseholdWithContext } = useContext(HouseholdContext);
|
||||||
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
|
const [mode, setMode] = useState(initialMode === "join" ? "join" : "create");
|
||||||
const [householdName, setHouseholdName] = useState("");
|
const [householdName, setHouseholdName] = useState("");
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteLink, setInviteLink] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@ -21,24 +38,6 @@ export default function CreateJoinHousehold({ initialMode = "create", onClose })
|
|||||||
setError("");
|
setError("");
|
||||||
}, [initialMode]);
|
}, [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) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!householdName.trim()) return;
|
if (!householdName.trim()) return;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
194
frontend/tests/invite-link-management.spec.ts
Normal file
194
frontend/tests/invite-link-management.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user