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..d7b5aad 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 (
-
- );
-}
-
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 (
-
+
);
@@ -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,30 @@ 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;
+ const next = new URLSearchParams(searchParams);
+ next.delete('add');
+ setSearchParams(next, { replace: true });
+ }, [searchParams, setSearchParams]);
const networkName = network?.name ?? 'Network';
const memberCount = network?.humans.length ?? 0;
@@ -223,121 +210,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 && (
+
+
+ {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 (
+
+ );
+}