385 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|