Now that MutationCache toasts via toUserMessage by default, the per-hook onError duplicates drop away. Also normalizes inline query-error UI and surfaces a previously-silent failure. Mutations — removed redundant onError toasts: - network-selector: useAcceptInvitation, createNetwork (inline) - network-settings: useInviteMembers, useRevokeInvitation, useRemoveMember - network-billing: useCreateCheckoutSession, useCreatePortalSession (onSuccess toasts stay — they carry domain context like network name) Queries — consistent inline error UX via toUserMessage: - network-selector: failed-to-load state gets a "Try again" button - network-billing: "Couldn't load billing" includes friendly reason - network-settings: useNetworkInvitations failure now surfaces a hint (previously rendered as "0 pending" — silently wrong) Trimmed noisy JSDoc from PR 1 files (errors.ts, query-client.ts, app-error-boundary.tsx, error-fallback.tsx, ipc-utils.ts).
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Muted } from "@/components/ui/typography";
|
|
import { WindowControls } from "@/components/window-controls";
|
|
import { useNetworks } from "@/hooks/use-networks";
|
|
import {
|
|
useNetworkInvitations,
|
|
useInviteMembers,
|
|
useRevokeInvitation,
|
|
useRemoveMember,
|
|
} from "@/hooks/use-member-management";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { BillingSection } from "@/features/network-billing";
|
|
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
|
import type { Human } from "@/api/types";
|
|
|
|
function MemberRow({
|
|
human,
|
|
isAdmin,
|
|
onRemove,
|
|
}: {
|
|
human: Human;
|
|
isAdmin: boolean;
|
|
onRemove?: () => void;
|
|
}) {
|
|
const initials = human.email_prefix.slice(0, 2).toUpperCase();
|
|
|
|
return (
|
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
|
<Avatar>
|
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
|
|
<Muted className="text-xs">{human.email}</Muted>
|
|
</div>
|
|
{isAdmin && (
|
|
<Badge variant="secondary" className="shrink-0">
|
|
<Shield className="mr-1 size-3" />
|
|
Admin
|
|
</Badge>
|
|
)}
|
|
{onRemove && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onRemove}
|
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
|
aria-label={`Remove ${human.email}`}
|
|
>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InviteForm({ networkId }: { networkId: string }) {
|
|
const [email, setEmail] = useState("");
|
|
const inviteMembers = useInviteMembers(networkId);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = email.trim();
|
|
if (!trimmed) return;
|
|
|
|
inviteMembers.mutate([trimmed], {
|
|
onSuccess: () => {
|
|
toast.success(`Invitation sent to ${trimmed}`);
|
|
setEmail("");
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
|
|
<Input
|
|
type="email"
|
|
placeholder="[email protected]"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={!email.trim() || inviteMembers.isPending}
|
|
>
|
|
{inviteMembers.isPending ? "Sending..." : "Invite"}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function PendingInvitationRow({
|
|
email,
|
|
networkId,
|
|
}: {
|
|
email: string;
|
|
networkId: string;
|
|
}) {
|
|
const revokeInvitation = useRevokeInvitation(networkId);
|
|
|
|
const handleRevoke = () => {
|
|
revokeInvitation.mutate(email, {
|
|
onSuccess: () => {
|
|
toast.success(`Invitation to ${email} revoked`);
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
|
<span className="text-muted-foreground flex size-8 items-center justify-center">
|
|
<Mail className="size-4" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm">{email}</p>
|
|
<Muted className="text-xs">Pending</Muted>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={handleRevoke}
|
|
disabled={revokeInvitation.isPending}
|
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
|
>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({
|
|
icon,
|
|
title,
|
|
description,
|
|
trailing,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
description?: string;
|
|
trailing?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
|
|
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
|
|
{icon}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
|
{trailing}
|
|
</div>
|
|
{description && <Muted className="text-xs">{description}</Muted>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default function NetworkSettingsPage() {
|
|
const navigate = useNavigate();
|
|
const { networkId } = useParams<{ networkId: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const { data: networks } = useNetworks();
|
|
const network = networks?.find((n) => n.id === networkId);
|
|
const { data: invitations, error: invitationsError } = useNetworkInvitations(networkId!);
|
|
const currentUser = useAuthStore((s) => s.user);
|
|
const isAdmin = currentUser?.id === network?.admin_human.id;
|
|
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
|
const removeMember = useRemoveMember(networkId!);
|
|
|
|
const billingRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (searchParams.get("section") === "billing") {
|
|
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const networkName = network?.name ?? "Network";
|
|
const memberCount = network?.humans.length ?? 0;
|
|
const pendingCount = invitations?.length ?? 0;
|
|
const networkInitials = networkName.slice(0, 2).toUpperCase();
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col">
|
|
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
|
|
<WindowControls />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="no-drag text-muted-foreground"
|
|
onClick={() => navigate(`/${networkId}`)}
|
|
>
|
|
<ArrowLeft className="size-3.5" />
|
|
</Button>
|
|
<span className="text-sm font-medium">Settings</span>
|
|
<div className="flex-1" />
|
|
</div>
|
|
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
|
|
<Avatar size="lg">
|
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
|
{networkInitials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-base font-semibold">{networkName}</p>
|
|
<Muted className="text-xs">
|
|
{memberCount} {memberCount === 1 ? "member" : "members"}
|
|
{isAdmin ? " · You're an admin" : ""}
|
|
</Muted>
|
|
</div>
|
|
</div>
|
|
|
|
<Section>
|
|
<SectionHeader
|
|
icon={<Users className="size-4" />}
|
|
title="Members"
|
|
description="People with access to this network."
|
|
trailing={
|
|
<Badge variant="secondary" className="tabular-nums">
|
|
{memberCount}
|
|
</Badge>
|
|
}
|
|
/>
|
|
<Separator />
|
|
{network?.humans.map((human, index) => {
|
|
const isRowAdmin = human.id === network.admin_human.id;
|
|
const canRemove =
|
|
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
|
|
return (
|
|
<div key={human.id}>
|
|
<MemberRow
|
|
human={human}
|
|
isAdmin={isRowAdmin}
|
|
onRemove={canRemove ? () => setMemberToRemove(human) : undefined}
|
|
/>
|
|
{index < network.humans.length - 1 && (
|
|
<Separator className="mx-4" />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Section>
|
|
|
|
{isAdmin && network && (
|
|
<Section>
|
|
<SectionHeader
|
|
icon={<Mail className="size-4" />}
|
|
title="Invitations"
|
|
description="Invite teammates by email. They'll get a link to join."
|
|
trailing={
|
|
pendingCount > 0 ? (
|
|
<Badge variant="secondary" className="tabular-nums">
|
|
{pendingCount} pending
|
|
</Badge>
|
|
) : undefined
|
|
}
|
|
/>
|
|
<Separator />
|
|
<InviteForm networkId={networkId!} />
|
|
{invitationsError && (
|
|
<>
|
|
<Separator />
|
|
<p className="text-muted-foreground px-4 py-3 text-xs">
|
|
Couldn't load pending invitations.
|
|
</p>
|
|
</>
|
|
)}
|
|
{pendingCount > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="px-4 pb-1 pt-3">
|
|
<Muted className="text-xs font-medium uppercase tracking-wider">
|
|
Pending
|
|
</Muted>
|
|
</div>
|
|
{invitations!.map((inv, index) => (
|
|
<div key={inv.email}>
|
|
<PendingInvitationRow
|
|
email={inv.email}
|
|
networkId={networkId!}
|
|
/>
|
|
{index < invitations!.length - 1 && (
|
|
<Separator className="mx-4" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
<div ref={billingRef}>
|
|
<Section>
|
|
<SectionHeader
|
|
icon={<CreditCard className="size-4" />}
|
|
title="Billing"
|
|
description={
|
|
isAdmin
|
|
? "Manage your plan, seats, and payment."
|
|
: "Your network's current plan and usage."
|
|
}
|
|
/>
|
|
<Separator />
|
|
<BillingSection networkId={networkId!} />
|
|
</Section>
|
|
</div>
|
|
|
|
<div className="h-6" />
|
|
</ScrollArea>
|
|
|
|
{memberToRemove && (
|
|
<ConfirmDestructiveOverlay
|
|
title={`Remove ${memberToRemove.email_prefix}?`}
|
|
description={
|
|
<ul className="list-disc space-y-1 pl-4">
|
|
<li>
|
|
They'll lose access to this network's streams and files within
|
|
seconds.
|
|
</li>
|
|
<li>Any content they posted stays in the network.</li>
|
|
<li>
|
|
If they're in a live huddle, they may remain until the call ends.
|
|
</li>
|
|
</ul>
|
|
}
|
|
confirmLabel="Remove"
|
|
pendingLabel="Removing…"
|
|
isPending={removeMember.isPending}
|
|
onConfirm={() => {
|
|
const target = memberToRemove;
|
|
removeMember.mutate(target.id, {
|
|
onSuccess: () => {
|
|
toast.success(`Removed ${target.email}`);
|
|
setMemberToRemove(null);
|
|
},
|
|
});
|
|
}}
|
|
onClose={() => {
|
|
if (!removeMember.isPending) setMemberToRemove(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|