feat: organize network settings into tabs (#264)

* implement

* fix: apply CodeRabbit auto-fixes

Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* nits

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit was merged in pull request #264.
This commit is contained in:
Arjun Patel
2026-06-11 10:21:19 -07:00
committed by GitHub
parent 849d41fa06
commit 9fd7e611f3
3 changed files with 350 additions and 164 deletions
+1 -1
View File
@@ -137,7 +137,7 @@ function CreateNetworkDialog({
toast.success(`Created ${network.name}`);
onOpenChange(false);
setName('');
navigate(`/${network.id}/settings`);
navigate(`/${network.id}/settings?section=members&add=1`);
},
});
+170 -163
View File
@@ -1,27 +1,37 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from 'lucide-react';
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 { Input } from '@/components/ui/input';
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,
useInviteMembers,
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,
@@ -65,43 +75,6 @@ function MemberRow({
);
}
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="name@example.com"
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,
@@ -141,36 +114,38 @@ function PendingInvitationRow({
);
}
function SectionHeader({
icon,
function SectionHeading({
title,
description,
trailing,
count,
action,
}: {
icon: React.ReactNode;
title: string;
description?: string;
trailing?: React.ReactNode;
count?: number;
action?: 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="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{trailing}
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
{count != null && (
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
)}
</div>
{description && <Muted className="text-xs">{description}</Muted>}
{description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
</div>
{action}
</div>
);
}
function Section({ children }: { children: React.ReactNode }) {
function Panel({ children }: { children: React.ReactNode }) {
return (
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
<section className="bg-card/40 overflow-hidden rounded-lg border">
{children}
</section>
);
@@ -181,7 +156,7 @@ export default function NetworkSettingsPage() {
const { networkId } = useParams<{ networkId: string }>();
if (!networkId)
throw new Error('NetworkSettingsPage requires a :networkId route param');
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations, error: invitationsError } =
@@ -189,18 +164,35 @@ export default function NetworkSettingsPage() {
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 billingRef = useRef<HTMLDivElement>(null);
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('section') === 'billing') {
billingRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [searchParams]);
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;
@@ -223,121 +215,136 @@ export default function NetworkSettingsPage() {
<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>
<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>
</div>
<TabsList variant="line" className="w-full gap-1">
<TabsTrigger value="members">
<Users />
Members
</TabsTrigger>
<TabsTrigger value="billing">
<CreditCard />
Plan & Billing
</TabsTrigger>
</TabsList>
</aside>
<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>
<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
}
/>
<Separator />
<InviteForm networkId={networkId} />
{invitationsError && (
<>
<Separator />
<p className="text-muted-foreground px-4 py-3 text-xs">
Couldn't load pending invitations.
</p>
</>
)}
{invitations && invitations.length > 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}
<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 < invitations.length - 1 && (
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
</Section>
)}
);
})}
</Panel>
<div ref={billingRef}>
<Section>
<SectionHeader
icon={<CreditCard className="size-4" />}
title="Billing"
{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."
}
/>
<Separator />
<BillingSection networkId={networkId} />
</Section>
</div>
<Panel>
<BillingSection networkId={networkId} />
</Panel>
</TabsContent>
</ScrollArea>
</Tabs>
<div className="h-6" />
</ScrollArea>
{isAdmin && (
<AddMembersDialog
networkId={networkId}
open={addOpen}
onOpenChange={setAddOpen}
/>
)}
{memberToRemove && (
<ConfirmDestructiveOverlay
@@ -0,0 +1,179 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Muted } from '@/components/ui/typography';
import { useInviteMembers } from '@/hooks/use-member-management';
const emailSchema = z.string().email();
function EmailChip({
email,
onRemove,
}: {
email: string;
onRemove: () => void;
}) {
return (
<span className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md py-0.5 pl-2 pr-1 text-xs">
{email}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
aria-label={`Remove ${email}`}
className="text-muted-foreground hover:text-foreground size-5"
>
<X className="size-3" />
</Button>
</span>
);
}
export function AddMembersDialog({
networkId,
open,
onOpenChange,
}: {
networkId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [emails, setEmails] = useState<string[]>([]);
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null);
const inviteMembers = useInviteMembers(networkId);
const reset = () => {
setEmails([]);
setInput('');
setError(null);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
// Commits the current input as a chip. Returns the next list of emails so
// callers (like submit) can act on the freshly-committed value.
const commit = (raw: string): string[] | null => {
const trimmed = raw.trim().replace(/,$/, '').trim();
if (!trimmed) return emails;
if (!emailSchema.safeParse(trimmed).success) {
setError(`"${trimmed}" doesn't look like a valid email.`);
return null;
}
if (emails.includes(trimmed)) {
setInput('');
return emails;
}
const next = [...emails, trimmed];
setEmails(next);
setInput('');
setError(null);
return next;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commit(input);
} else if (e.key === 'Backspace' && input === '' && emails.length > 0) {
setEmails(emails.slice(0, -1));
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const next = commit(input);
if (next === null) return; // invalid pending input
if (next.length === 0) return;
inviteMembers.mutate(next, {
onSuccess: () => {
toast.success(
next.length === 1
? `Invited ${next[0]}`
: `Invited ${next.length} people`,
);
handleOpenChange(false);
},
});
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add members</DialogTitle>
<DialogDescription>
Enter email addresses to add people to this network.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="border-input focus-within:border-ring focus-within:ring-ring/50 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 transition-colors focus-within:ring-[3px]">
{emails.map((email) => (
<EmailChip
key={email}
email={email}
onRemove={() => setEmails(emails.filter((x) => x !== email))}
/>
))}
<Input
type="email"
value={input}
onChange={(e) => {
setInput(e.target.value);
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
onBlur={() => commit(input)}
placeholder={
emails.length === 0 ? 'name@example.com' : 'Add another…'
}
className="h-7 min-w-[8rem] flex-1 border-0 px-1 shadow-none focus-visible:ring-0"
autoFocus
/>
</div>
{error ? (
<p className="text-destructive mt-1.5 text-xs">{error}</p>
) : (
<Muted className="mt-1.5 text-xs">
Press Enter or comma to add each email.
</Muted>
)}
<DialogFooter className="mt-4">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={
inviteMembers.isPending ||
(emails.length === 0 && input.trim() === '')
}
>
{inviteMembers.isPending ? 'Adding…' : 'Add members'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}