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:
@@ -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`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user