From 9fd7e611f31d09957648ccb5117fc12bbdc6c0bf Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Thu, 11 Jun 2026 10:21:19 -0700 Subject: [PATCH] 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 * nits --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit --- js/desktop/src/features/network-selector.tsx | 2 +- js/desktop/src/features/network-settings.tsx | 333 +++++++++--------- .../network-settings/add-members-dialog.tsx | 179 ++++++++++ 3 files changed, 350 insertions(+), 164 deletions(-) create mode 100644 js/desktop/src/features/network-settings/add-members-dialog.tsx diff --git a/js/desktop/src/features/network-selector.tsx b/js/desktop/src/features/network-selector.tsx index caef58c..f3f3829 100644 --- a/js/desktop/src/features/network-selector.tsx +++ b/js/desktop/src/features/network-selector.tsx @@ -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`); }, }); diff --git a/js/desktop/src/features/network-settings.tsx b/js/desktop/src/features/network-settings.tsx index 9f7b972..76586ee 100644 --- a/js/desktop/src/features/network-settings.tsx +++ b/js/desktop/src/features/network-settings.tsx @@ -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 ( -
- setEmail(e.target.value)} - className="flex-1" - /> - -
- ); -} - 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 ( -
- - {icon} - -
+
+
-

{title}

- {trailing} +

{title}

+ {count != null && ( + + {count} + + )}
- {description && {description}} + {description && {description}}
+ {action}
); } -function Section({ children }: { children: React.ReactNode }) { +function Panel({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -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(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(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() {
- -
- - - {networkInitials} - - -
-

{networkName}

- - {memberCount} {memberCount === 1 ? 'member' : 'members'} - {isAdmin ? " · You're an admin" : ''} - + +
+ + + + Members + + + + Plan & Billing + + + -
- } - title="Members" - description="People with access to this network." - trailing={ - - {memberCount} - - } - /> - - {network?.humans.map((human, index) => { - const isRowAdmin = human.id === network.admin_human.id; - const canRemove = - isAdmin && !isRowAdmin && human.id !== currentUser?.id; - return ( -
- setMemberToRemove(human) : undefined - } - /> - {index < network.humans.length - 1 && ( - - )} -
- ); - })} -
- - {isAdmin && network && ( -
- } - title="Invitations" - description="Invite teammates by email. They'll get a link to join." - trailing={ - pendingCount > 0 ? ( - - {pendingCount} pending - + + + setAddOpen(true)} + > + + Add members + ) : undefined } /> - - - {invitationsError && ( - <> - -

- Couldn't load pending invitations. -

- - )} - {invitations && invitations.length > 0 && ( - <> - -
- - Pending - -
- {invitations.map((inv, index) => ( -
- + {network?.humans.map((human, index) => { + const isRowAdmin = human.id === network.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUser?.id; + return ( +
+ setMemberToRemove(human) : undefined + } /> - {index < invitations.length - 1 && ( + {index < network.humans.length - 1 && ( )}
- ))} - - )} -
- )} + ); + })} + -
-
- } - title="Billing" + {isAdmin && ( +
+ 0 ? pendingCount : undefined} + /> + {invitationsError ? ( + +

+ Couldn't load pending invitations. +

+
+ ) : invitations && invitations.length > 0 ? ( + + {invitations.map((inv, index) => ( +
+ + {index < invitations.length - 1 && ( + + )} +
+ ))} +
+ ) : ( + No pending invitations. + )} +
+ )} + + + + - - -
-
+ + + + + + -
- + {isAdmin && ( + + )} {memberToRemove && ( void; +}) { + return ( + + {email} + + + ); +} + +export function AddMembersDialog({ + networkId, + open, + onOpenChange, +}: { + networkId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [emails, setEmails] = useState([]); + const [input, setInput] = useState(''); + const [error, setError] = useState(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) => { + 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 ( + + + + Add members + + Enter email addresses to add people to this network. + + +
+
+ {emails.map((email) => ( + setEmails(emails.filter((x) => x !== email))} + /> + ))} + { + 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 + /> +
+ {error ? ( +

{error}

+ ) : ( + + Press Enter or comma to add each email. + + )} + + + + +
+
+
+ ); +}