feat: invite members and manage members

This commit is contained in:
talksik
2026-03-24 19:47:23 -07:00
parent bf0147d542
commit 7eeb25059d
14 changed files with 655 additions and 39 deletions
+12 -9
View File
@@ -87,9 +87,10 @@ type MembersRequest struct {
}
type Invitation struct {
NetworkId string `json:"network_id"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
NetworkId string `json:"network_id"`
NetworkName string `json:"network_name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
type AcceptInvitationRequest struct {
@@ -527,9 +528,10 @@ func (h *Handler) ListInvitationsForNetwork(w http.ResponseWriter, r *http.Reque
resp := make([]Invitation, 0, len(invitations))
for _, inv := range invitations {
resp = append(resp, Invitation{
NetworkId: inv.NetworkID,
Email: inv.Email,
CreatedAt: inv.CreatedAt,
NetworkId: inv.NetworkID,
NetworkName: inv.NetworkName,
Email: inv.Email,
CreatedAt: inv.CreatedAt,
})
}
@@ -555,9 +557,10 @@ func (h *Handler) ListMyInvitations(w http.ResponseWriter, r *http.Request) {
resp := make([]Invitation, 0, len(invitations))
for _, inv := range invitations {
resp = append(resp, Invitation{
NetworkId: inv.NetworkID,
Email: inv.Email,
CreatedAt: inv.CreatedAt,
NetworkId: inv.NetworkID,
NetworkName: inv.NetworkName,
Email: inv.Email,
CreatedAt: inv.CreatedAt,
})
}
+4 -3
View File
@@ -13,7 +13,8 @@ type Network struct {
}
type Invitation struct {
NetworkID string
Email string
CreatedAt time.Time
NetworkID string
NetworkName string
Email string
CreatedAt time.Time
}
+10 -4
View File
@@ -215,7 +215,10 @@ func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email
func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) {
rows, err := r.pool.Query(ctx,
`SELECT network_id, email, created_at FROM network_invitations WHERE email = $1`,
`SELECT ni.network_id, n.name, ni.email, ni.created_at
FROM network_invitations ni
JOIN networks n ON n.id = ni.network_id
WHERE ni.email = $1`,
email,
)
if err != nil {
@@ -226,7 +229,7 @@ func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string
var invitations []*Invitation
for rows.Next() {
var inv Invitation
if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil {
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
return nil, err
}
invitations = append(invitations, &inv)
@@ -236,7 +239,10 @@ func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string
func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
rows, err := r.pool.Query(ctx,
`SELECT network_id, email, created_at FROM network_invitations WHERE network_id = $1`,
`SELECT ni.network_id, n.name, ni.email, ni.created_at
FROM network_invitations ni
JOIN networks n ON n.id = ni.network_id
WHERE ni.network_id = $1`,
networkID,
)
if err != nil {
@@ -247,7 +253,7 @@ func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID
var invitations []*Invitation
for rows.Next() {
var inv Invitation
if err := rows.Scan(&inv.NetworkID, &inv.Email, &inv.CreatedAt); err != nil {
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
return nil, err
}
invitations = append(invitations, &inv)
+2
View File
@@ -54,11 +54,13 @@
"electron-squirrel-startup": "^1.0.1",
"firebase": "^12.10.0",
"lucide-react": "^0.575.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0",
+4
View File
@@ -13,7 +13,9 @@ import NetworkSelector from "@/features/network-selector";
import NetworkRoot from "@/features/network-root";
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
import Layout from "@/features/layout";
import NetworkSettingsPage from "@/features/network-settings";
import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner";
const queryClient = new QueryClient();
@@ -50,6 +52,7 @@ function AuthenticatedApp() {
<Route index element={<Layout><NetworkSelector /></Layout>} />
<Route path=":networkId">
<Route index element={<Layout><NetworkRoot /></Layout>} />
<Route path="settings" element={<NetworkSettingsPage />} />
<Route path="*" element={<ParticleViewResolver />} />
</Route>
</Route>
@@ -62,6 +65,7 @@ const AppWithProviders = () => (
<TooltipProvider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster />
</QueryClientProvider>
</TooltipProvider>
);
+29
View File
@@ -3,16 +3,19 @@ import type { z } from "zod";
import {
DepotObjectSchema,
HumanSchema,
ListInvitationsResponseSchema,
ListNetworksResponseSchema,
NetworkSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from "./types";
import type {
AcceptInvitationRequest,
AddMembersRequest,
CreateNetworkRequest,
PrepareUploadRequest,
RequestCodeRequest,
RevokeInvitationRequest,
SignInRequest,
} from "./types";
@@ -173,6 +176,32 @@ class ApiClient {
`/networks/${networkId}/members/${email}`,
);
}
// --- Invitations ---
async listNetworkInvitations(networkId: string) {
return this.request(
ListInvitationsResponseSchema,
"GET",
`/networks/${networkId}/invitations`,
);
}
async listMyInvitations() {
return this.request(
ListInvitationsResponseSchema,
"GET",
"/invitations",
);
}
async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
await this.requestVoid("POST", "/invitations/accept", data);
}
async revokeInvitation(networkId: string, data: RevokeInvitationRequest): Promise<void> {
await this.requestVoid("DELETE", `/networks/${networkId}/invitations`, data);
}
}
export const apiClient = new ApiClient({
+15
View File
@@ -34,6 +34,21 @@ const AddMembersRequestSchema = z.object({
});
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
// --- Invitation types ---
export const InvitationSchema = z.object({
network_id: z.string(),
network_name: z.string(),
email: z.string(),
created_at: z.coerce.date(),
});
export type Invitation = z.infer<typeof InvitationSchema>;
export const ListInvitationsResponseSchema = z.array(InvitationSchema);
export type AcceptInvitationRequest = { network_id: string };
export type RevokeInvitationRequest = { email: string };
// --- Depot types ---
const PrepareUploadRequestSchema = z.object({
+44
View File
@@ -0,0 +1,44 @@
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+12 -1
View File
@@ -1,7 +1,7 @@
import { WindowControls } from "@/components/window-controls";
import { Button } from "@/components/ui/button";
import { useNavigate, useParams } from "react-router-dom";
import { Home, Settings, Volume2, VolumeOff } from "lucide-react";
import { Home, Settings, Users, Volume2, VolumeOff } from "lucide-react";
import { Toggle } from "@/components/ui/toggle";
import { useAutoplayStore } from "@/stores/autoplay-store";
import {
@@ -137,6 +137,17 @@ function TopBar() {
<AutoplayToggle />
{networkId && (
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(`/${networkId}/settings`)}
>
<Users className="size-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
+230 -22
View File
@@ -1,19 +1,36 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Users } from "lucide-react";
import { Check, Plus, Settings, Users } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Small } from "@/components/ui/typography";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useNetworks } from "@/hooks/use-networks";
import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-invitations";
import { apiClient } from "@/api/client";
import { Progress } from "@/components/ui/progress";
import type { Network } from "@/api/types";
import type { Network, Invitation } from "@/api/types";
function NetworkRow({
network,
onClick,
onSettingsClick,
}: {
network: Network;
onClick: () => void;
onSettingsClick: () => void;
}) {
const initials = network.name.slice(0, 2).toUpperCase();
const memberCount = network.humans.length;
@@ -22,7 +39,7 @@ function NetworkRow({
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
@@ -38,14 +55,142 @@ function NetworkRow({
</Small>
</div>
</div>
<span
role="button"
tabIndex={0}
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onSettingsClick();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onSettingsClick();
}
}}
>
<Settings className="size-4" />
</span>
</button>
);
}
function InvitationRow({ invitation }: { invitation: Invitation }) {
const acceptInvitation = useAcceptInvitation();
const initials = invitation.network_name.slice(0, 2).toUpperCase();
const handleAccept = () => {
acceptInvitation.mutate(invitation.network_id, {
onSuccess: () => {
toast.success(`Joined ${invitation.network_name}`);
},
onError: (err) => {
toast.error(err.message || "Failed to accept invitation");
},
});
};
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">
{invitation.network_name}
</p>
<Small className="text-muted-foreground">You've been invited</Small>
</div>
<Button
size="sm"
onClick={handleAccept}
disabled={acceptInvitation.isPending}
>
<Check className="mr-1 size-3.5" />
{acceptInvitation.isPending ? "Joining..." : "Accept"}
</Button>
</div>
);
}
function CreateNetworkDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [name, setName] = useState("");
const navigate = useNavigate();
const queryClient = useQueryClient();
const createNetwork = useMutation({
mutationFn: (networkName: string) =>
apiClient.createNetwork({ name: networkName }),
onSuccess: (network) => {
queryClient.invalidateQueries({ queryKey: ["networks"] });
toast.success(`Created ${network.name}`);
onOpenChange(false);
setName("");
navigate(`/${network.id}/settings`);
},
onError: (err) => {
toast.error(err.message || "Failed to create network");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
createNetwork.mutate(trimmed);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Network</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="py-4">
<Input
placeholder="Network name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!name.trim() || createNetwork.isPending}
>
{createNetwork.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default function NetworkSelector() {
const navigate = useNavigate();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const { data, isPending, error } = useNetworks();
const { data: networks, isPending, error } = useNetworks();
const { data: invitations } = useMyInvitations();
if (isPending) {
return <Progress />;
@@ -60,33 +205,96 @@ export default function NetworkSelector() {
);
}
if (data?.length === 0) {
const hasInvitations = invitations && invitations.length > 0;
const hasNetworks = networks && networks.length > 0;
if (!hasNetworks && !hasInvitations) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
<p className="text-muted-foreground">
You don't have access to any networks yet. Please email us to get
started.
You don't have access to any networks yet. Create one or ask your admin for an invite.
</p>
<a href="mailto:team@flowylabs.ai" className="text-primary underline">
team@flowylabs.ai
</a>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-1 size-3.5" />
Create Network
</Button>
<CreateNetworkDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="py-1">
{data?.map((network, index) => (
<div key={network.id}>
<NetworkRow
network={network}
onClick={() => navigate(`/${network.id}`)}
/>
{index < data.length - 1 && <Separator className="mx-4" />}
<>
<ScrollArea className="h-full">
<div className="py-1">
{hasInvitations && (
<>
<div className="flex items-center gap-2 px-4 pb-1 pt-4">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wider">
Pending Invitations
</p>
<Badge variant="secondary" className="text-xs">
{invitations.length}
</Badge>
</div>
{invitations.map((inv, index) => (
<div key={`${inv.network_id}-${inv.email}`}>
<InvitationRow invitation={inv} />
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
<Separator className="mx-4 mt-2" />
</>
)}
{hasNetworks && (
<>
{hasInvitations && (
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
Your Networks
</p>
)}
{networks.map((network, index) => (
<div key={network.id}>
<NetworkRow
network={network}
onClick={() => navigate(`/${network.id}`)}
onSettingsClick={() =>
navigate(`/${network.id}/settings`)
}
/>
{index < networks.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</>
)}
<Separator className="mx-4 mt-2" />
<div className="px-4 py-3">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-1 size-3.5" />
New Network
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
</ScrollArea>
<CreateNetworkDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Mail, Shield, 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 { Muted } from "@/components/ui/typography";
import { WindowControls } from "@/components/window-controls";
import { useNetworks } from "@/hooks/use-networks";
import {
useNetworkInvitations,
useInviteMembers,
useRevokeInvitation,
} from "@/hooks/use-invitations";
import type { Human } from "@/api/types";
function MemberRow({
human,
isAdmin,
}: {
human: Human;
isAdmin: boolean;
}) {
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>
)}
</div>
);
}
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("");
},
onError: (err) => {
toast.error(err.message || "Failed to send invitation");
},
});
};
return (
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
<Input
type="email"
placeholder="Email address"
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,
}: {
email: string;
networkId: string;
}) {
const revokeInvitation = useRevokeInvitation(networkId);
const handleRevoke = () => {
revokeInvitation.mutate(email, {
onSuccess: () => {
toast.success(`Invitation to ${email} revoked`);
},
onError: (err) => {
toast.error(err.message || "Failed to revoke invitation");
},
});
};
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-10 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 SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
{title}
</p>
<div>{children}</div>
</div>
);
}
export default function NetworkSettingsPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const { data: invitations } = useNetworkInvitations(networkId!);
const networkName = network?.name ?? "Network";
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">{networkName}</span>
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
<SettingsGroup title="Members">
{network?.humans.map((human, index) => (
<div key={human.id}>
<MemberRow
human={human}
isAdmin={human.id === network.admin_human.id}
/>
{index < network.humans.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))}
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Invite">
<InviteForm networkId={networkId!} />
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Pending Invitations">
{invitations && invitations.length > 0 ? (
invitations.map((inv, index) => (
<div key={inv.email}>
<PendingInvitationRow
email={inv.email}
networkId={networkId!}
/>
{index < invitations.length - 1 && (
<Separator className="mx-4" />
)}
</div>
))
) : (
<p className="text-muted-foreground px-4 py-3 text-sm">
No pending invitations
</p>
)}
</SettingsGroup>
</ScrollArea>
</div>
);
}
@@ -295,6 +295,17 @@ export function ParticleListView({ path }: ParticleListViewProps) {
return <Progress />;
}
if (streams.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No recent streams yet. Start a conversation using the keyboard shortcuts below.
</p>
</div>
);
}
return (
<div className="h-full overflow-clip">
<div className="py-1">
+52
View File
@@ -0,0 +1,52 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useMyInvitations() {
return useQuery({
queryKey: ["my-invitations"],
queryFn: () => apiClient.listMyInvitations(),
refetchInterval: 10_000,
});
}
export function useNetworkInvitations(networkId: string) {
return useQuery({
queryKey: ["network-invitations", networkId],
queryFn: () => apiClient.listNetworkInvitations(networkId),
});
}
export function useInviteMembers(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (emailAddresses: string[]) =>
apiClient.addMembers(networkId, { email_addresses: emailAddresses }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (networkId: string) =>
apiClient.acceptInvitation({ network_id: networkId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["my-invitations"] });
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
}
export function useRevokeInvitation(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
apiClient.revokeInvitation(networkId, { email }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["network-invitations", networkId] });
},
});
}
+10
View File
@@ -6723,6 +6723,11 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
next-themes@^0.4.6:
version "0.4.6"
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6"
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
@@ -8042,6 +8047,11 @@ socks@^2.6.2:
ip-address "^10.0.1"
smart-buffer "^4.2.0"
sonner@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.7.tgz#810c1487a67ec3370126e0f400dfb9edddc3e4f6"
integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"