Files
llink/js/desktop/src/features/network-settings.tsx
T
2026-06-11 10:14:31 -07:00

385 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
CreditCard,
Mail,
Shield,
UserPlus,
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 { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Muted } from '@/components/ui/typography';
import { WindowControls } from '@/components/window-controls';
import { useNetworks } from '@/hooks/use-networks';
import {
useNetworkInvitations,
useRevokeInvitation,
useRemoveMember,
} from '@/hooks/use-member-management';
import { useAuthStore } from '@/stores/auth-store';
import { BillingSection } from '@/features/network-billing';
import { AddMembersDialog } from '@/features/network-settings/add-members-dialog';
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
import type { Human } from '@/api/types';
type Section = 'members' | 'billing';
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 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 SectionHeading({
title,
description,
count,
action,
}: {
title: string;
description?: string;
count?: number;
action?: React.ReactNode;
}) {
return (
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
{count != null && (
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
)}
</div>
{description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
</div>
{action}
</div>
);
}
function Panel({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 overflow-hidden rounded-lg border">
{children}
</section>
);
}
export default function NetworkSettingsPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
if (!networkId)
throw new Error('NetworkSettingsPage requires a :networkId route param');
const [searchParams, setSearchParams] = 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);
// Onboarding: opening settings with `?add=1` (e.g. right after creating a
// network) starts with the Add members dialog open. Non-admins never render
// the dialog, so the initial value is harmless for them.
const [addOpen, setAddOpen] = useState(() => searchParams.get('add') === '1');
const removeMember = useRemoveMember(networkId);
const section: Section =
searchParams.get('section') === 'billing' ? 'billing' : 'members';
const setSection = (value: string) => {
const next = new URLSearchParams(searchParams);
if (value === 'billing') next.set('section', value);
else next.delete('section');
setSearchParams(next, { replace: true });
};
// Strip the one-shot `add` param so the dialog doesn't reopen on refresh or
// back navigation. The initial open state was already captured above.
useEffect(() => {
if (searchParams.get('add') !== '1') return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('add');
return next;
},
{ replace: true },
);
}, [setSearchParams, 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>
<Tabs
value={section}
onValueChange={setSection}
orientation="vertical"
className="min-h-0 flex-1 gap-0"
>
<aside className="flex w-52 shrink-0 flex-col gap-4 border-r p-3">
<div className="flex items-center gap-3 px-1 pt-1">
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{networkInitials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{networkName}</p>
<Muted className="text-xs">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</Muted>
</div>
</div>
<TabsList variant="line" className="w-full gap-1">
<TabsTrigger value="members">
<Users />
Members
</TabsTrigger>
<TabsTrigger value="billing">
<CreditCard />
Plan & Billing
</TabsTrigger>
</TabsList>
</aside>
<ScrollArea className="min-h-0 flex-1">
<TabsContent value="members" className="p-4">
<SectionHeading
title="Members"
description="People with access to this network."
count={memberCount}
action={
isAdmin ? (
<Button
size="sm"
className="shrink-0"
onClick={() => setAddOpen(true)}
>
<UserPlus className="mr-1 size-3.5" />
Add members
</Button>
) : undefined
}
/>
<Panel>
{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>
);
})}
</Panel>
{isAdmin && (
<div className="mt-6">
<SectionHeading
title="Pending invitations"
description="Invites that haven't been accepted yet."
count={pendingCount > 0 ? pendingCount : undefined}
/>
{invitationsError ? (
<Panel>
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</Panel>
) : invitations && invitations.length > 0 ? (
<Panel>
{invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId}
/>
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</Panel>
) : (
<Muted className="text-xs">No pending invitations.</Muted>
)}
</div>
)}
</TabsContent>
<TabsContent value="billing" className="p-4">
<SectionHeading
title="Plan & Billing"
description={
isAdmin
? 'Manage your plan, seats, and payment.'
: "Your network's current plan and usage."
}
/>
<Panel>
<BillingSection networkId={networkId} />
</Panel>
</TabsContent>
</ScrollArea>
</Tabs>
{isAdmin && (
<AddMembersDialog
networkId={networkId}
open={addOpen}
onOpenChange={setAddOpen}
/>
)}
{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>
);
}