chore: harden reliability checks #2
@ -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));
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
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