Compare commits

...
Author SHA1 Message Date
Eduard Gert e167afb06b Fix missing groups on users and dns settings page 2024-11-13 16:18:54 +01:00
35 changed files with 252 additions and 67 deletions

No files matched your search

+2 -1
View File
@@ -12,5 +12,6 @@
"tokenSource": "$NETBIRD_TOKEN_SOURCE", "tokenSource": "$NETBIRD_TOKEN_SOURCE",
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS", "dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID", "hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID" "googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"authServiceUrl": "$NETBIRD_AUTH_SERVICE_URL"
} }
+22 -7
View File
@@ -14,17 +14,24 @@ import { IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api"; import useFetchApi, { useApiCall } from "@utils/api";
import { ExternalLinkIcon } from "lucide-react"; import { ExternalLinkIcon } from "lucide-react";
import React from "react"; import React from "react";
import Skeleton from "react-loading-skeleton";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import DNSIcon from "@/assets/icons/DNSIcon"; import DNSIcon from "@/assets/icons/DNSIcon";
import { useHasChanges } from "@/hooks/useHasChanges"; import { useHasChanges } from "@/hooks/useHasChanges";
import { Group } from "@/interfaces/Group";
import { NameserverSettings } from "@/interfaces/NameserverSettings"; import { NameserverSettings } from "@/interfaces/NameserverSettings";
import PageContainer from "@/layouts/PageContainer"; import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper"; import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
export default function NameServerSettings() { export default function NameServerSettings() {
const { data: settings, isLoading } = const { data: settings, isLoading } =
useFetchApi<NameserverSettings>("/dns/settings"); useFetchApi<NameserverSettings>("/dns/settings");
const initialDNSGroups = useGroupIdsToGroups(
settings?.disabled_management_groups,
);
return ( return (
<PageContainer> <PageContainer>
<div className={"p-default py-6"}> <div className={"p-default py-6"}>
@@ -55,10 +62,16 @@ export default function NameServerSettings() {
in our documentation. in our documentation.
</Paragraph> </Paragraph>
<RestrictedAccess page={"DNS Settings"}> <RestrictedAccess page={"DNS Settings"}>
{!isLoading && ( {!isLoading && initialDNSGroups !== undefined ? (
<SettingDisabledManagementGroups <SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
initial={settings?.disabled_management_groups} ) : (
/> <div>
<Skeleton
width={"100%"}
className={"mt-8 max-w-xl"}
height={240}
/>
</div>
)} )}
</RestrictedAccess> </RestrictedAccess>
</div> </div>
@@ -67,16 +80,16 @@ export default function NameServerSettings() {
} }
const SettingDisabledManagementGroups = ({ const SettingDisabledManagementGroups = ({
initial, initialGroups,
}: { }: {
initial: string[] | undefined; initialGroups: Group[];
}) => { }) => {
const settingRequest = useApiCall<NameserverSettings>("/dns/settings"); const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const [selectedGroups, setSelectedGroups, { save: saveGroups }] = const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({ useGroupHelper({
initial: initial || [], initial: initialGroups,
}); });
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([ const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
@@ -108,6 +121,7 @@ const SettingDisabledManagementGroups = ({
Peers in these groups will require manual domain name resolution Peers in these groups will require manual domain name resolution
</HelpText> </HelpText>
<PeerGroupSelector <PeerGroupSelector
dataCy={"dns-groups-selector"}
onChange={setSelectedGroups} onChange={setSelectedGroups}
values={selectedGroups} values={selectedGroups}
/> />
@@ -122,6 +136,7 @@ const SettingDisabledManagementGroups = ({
size={"sm"} size={"sm"}
onClick={saveSettings} onClick={saveSettings}
disabled={!hasChanges} disabled={!hasChanges}
data-cy={"save-changes"}
> >
Save Changes Save Changes
</Button> </Button>
+5 -1
View File
@@ -11,6 +11,7 @@ import {
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Role } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer"; import PageContainer from "@/layouts/PageContainer";
import { useAccount } from "@/modules/account/useAccount"; import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab"; import AuthenticationTab from "@/modules/settings/AuthenticationTab";
@@ -52,7 +53,10 @@ export default function NetBirdSettings() {
Danger zone Danger zone
</VerticalTabs.Trigger> </VerticalTabs.Trigger>
</VerticalTabs.List> </VerticalTabs.List>
<RestrictedAccess page={"Settings"}> <RestrictedAccess
page={"Settings"}
allow={[Role.Admin, Role.Owner, Role.BillingAdmin]}
>
<div className={"border-l border-nb-gray-930 w-full"}> <div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />} {account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />} {account && <PermissionsTab account={account} />}
+15 -5
View File
@@ -22,11 +22,13 @@ import { useSWRConfig } from "swr";
import TeamIcon from "@/assets/icons/TeamIcon"; import TeamIcon from "@/assets/icons/TeamIcon";
import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useHasChanges } from "@/hooks/useHasChanges"; import { useHasChanges } from "@/hooks/useHasChanges";
import { Group } from "@/interfaces/Group";
import { Role, User } from "@/interfaces/User"; import { Role, User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer"; import PageContainer from "@/layouts/PageContainer";
import AccessTokensTable from "@/modules/access-tokens/AccessTokensTable"; import AccessTokensTable from "@/modules/access-tokens/AccessTokensTable";
import CreateAccessTokenModal from "@/modules/access-tokens/CreateAccessTokenModal"; import CreateAccessTokenModal from "@/modules/access-tokens/CreateAccessTokenModal";
import useGroupHelper from "@/modules/groups/useGroupHelper"; import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell"; import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell"; import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector"; import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
@@ -45,8 +47,10 @@ export default function UserPage() {
useRedirect("/team/users", false, !userId); useRedirect("/team/users", false, !userId);
return !isLoading && user ? ( const userGroups = useGroupIdsToGroups(user?.auto_groups);
<UserOverview user={user} />
return !isLoading && user && userGroups !== undefined ? (
<UserOverview user={user} initialGroups={userGroups} />
) : ( ) : (
<FullScreenLoading /> <FullScreenLoading />
); );
@@ -54,16 +58,16 @@ export default function UserPage() {
type Props = { type Props = {
user: User; user: User;
initialGroups: Group[];
}; };
function UserOverview({ user }: Props) { function UserOverview({ user, initialGroups }: Readonly<Props>) {
const router = useRouter(); const router = useRouter();
const userRequest = useApiCall<User>("/users"); const userRequest = useApiCall<User>("/users");
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser(); const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false; const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const initialGroups = user.auto_groups;
const [selectedGroups, setSelectedGroups, { save: saveGroups }] = const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({ useGroupHelper({
initial: initialGroups, initial: initialGroups,
@@ -180,6 +184,7 @@ function UserOverview({ user }: Props) {
className={"w-full"} className={"w-full"}
disabled={!hasChanges} disabled={!hasChanges}
onClick={save} onClick={save}
data-cy={"save-changes"}
> >
Save Changes Save Changes
</Button> </Button>
@@ -201,6 +206,7 @@ function UserOverview({ user }: Props) {
onChange={setSelectedGroups} onChange={setSelectedGroups}
values={selectedGroups} values={selectedGroups}
hideAllGroup={true} hideAllGroup={true}
dataCy={"user-group-selector"}
/> />
</div> </div>
)} )}
@@ -244,7 +250,10 @@ function UserOverview({ user }: Props) {
<div className={"inline-flex gap-4 justify-end"}> <div className={"inline-flex gap-4 justify-end"}>
<div> <div>
<CreateAccessTokenModal user={user}> <CreateAccessTokenModal user={user}>
<Button variant={"primary"}> <Button
variant={"primary"}
data-cy={"access-token-open-modal"}
>
<IconCirclePlus size={16} /> <IconCirclePlus size={16} />
Create Access Token Create Access Token
</Button> </Button>
@@ -293,6 +302,7 @@ function UserInformationCard({ user }: { user: User }) {
)} )}
<Card.ListItem <Card.ListItem
tooltip={false}
label={ label={
<> <>
<GalleryHorizontalEnd size={16} /> <GalleryHorizontalEnd size={16} />
+3 -1
View File
@@ -10,12 +10,14 @@ import useFetchApi from "@utils/api";
import { ExternalLinkIcon, User2 } from "lucide-react"; import { ExternalLinkIcon, User2 } from "lucide-react";
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import TeamIcon from "@/assets/icons/TeamIcon"; import TeamIcon from "@/assets/icons/TeamIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { User } from "@/interfaces/User"; import { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer"; import PageContainer from "@/layouts/PageContainer";
const UsersTable = lazy(() => import("@/modules/users/UsersTable")); const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
export default function TeamUsers() { export default function TeamUsers() {
const { isLoading: isGroupsLoading } = useGroups();
const { data: users, isLoading } = useFetchApi<User[]>( const { data: users, isLoading } = useFetchApi<User[]>(
"/users?service_user=false", "/users?service_user=false",
); );
@@ -60,7 +62,7 @@ export default function TeamUsers() {
<Suspense fallback={<SkeletonTable />}> <Suspense fallback={<SkeletonTable />}>
<UsersTable <UsersTable
users={users} users={users}
isLoading={isLoading} isLoading={isLoading || isGroupsLoading}
headingTarget={portalTarget} headingTarget={portalTarget}
/> />
</Suspense> </Suspense>
+5 -2
View File
@@ -241,7 +241,7 @@ export function PeerGroupSelector({
)} )}
</div> </div>
<div className={"pl-2"}> <div className={"pl-2"} data-cy={"group-selector-open-close"}>
<ChevronsUpDown <ChevronsUpDown
size={18} size={18}
className={"shrink-0 group-hover:text-nb-gray-300 transition-all"} className={"shrink-0 group-hover:text-nb-gray-300 transition-all"}
@@ -311,7 +311,10 @@ export function PeerGroupSelector({
<CommandGroup> <CommandGroup>
<ScrollArea <ScrollArea
className={"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3"} className={cn(
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
sortedDropdownOptions.length == 0 && !search && "py-0",
)}
> >
{searchedGroupNotFound && ( {searchedGroupNotFound && (
<CommandItem <CommandItem
@@ -4,7 +4,7 @@ export const GradientFadedBackground = () => {
return ( return (
<div <div
className={ className={
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0" "h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none"
} }
> >
<div <div
+1
View File
@@ -27,6 +27,7 @@ export default function GroupBadge({
<Badge <Badge
key={group.id || group.name} key={group.id || group.name}
useHover={true} useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"} variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)} className={cn("transition-all group whitespace-nowrap", className)}
onClick={(e) => { onClick={(e) => {
+4 -1
View File
@@ -34,7 +34,10 @@ export default function MultipleGroups({
<TooltipProvider disableHoverableContent={false}> <TooltipProvider disableHoverableContent={false}>
<Tooltip delayDuration={1}> <Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}> <TooltipTrigger asChild={true}>
<div className={"inline-flex items-center gap-2 z-0"}> <div
className={"inline-flex items-center gap-2 z-0"}
data-cy={"multiple-groups"}
>
{firstGroup && <GroupBadge group={firstGroup} />} {firstGroup && <GroupBadge group={firstGroup} />}
{otherGroups && otherGroups.length > 0 && ( {otherGroups && otherGroups.length > 0 && (
<Badge <Badge
+1 -2
View File
@@ -34,9 +34,8 @@ export default function UserDropdown() {
}; };
useHotkeys("shift+mod+l", () => logout(), []); useHotkeys("shift+mod+l", () => logout(), []);
const { isOwnerOrAdmin, permission, isBillingAdmin } = useLoggedInUser();
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const { permission } = useLoggedInUser();
return ( return (
<DropdownMenu <DropdownMenu
+1 -3
View File
@@ -1,6 +1,5 @@
import useFetchApi, { useApiCall } from "@utils/api"; import useFetchApi, { useApiCall } from "@utils/api";
import { merge, sortBy, unionBy } from "lodash"; import { merge, sortBy, unionBy } from "lodash";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group"; import { Group } from "@/interfaces/Group";
@@ -25,10 +24,9 @@ const GroupContext = React.createContext(
); );
export default function GroupsProvider({ children }: Props) { export default function GroupsProvider({ children }: Props) {
const path = usePathname();
const { permission } = useLoggedInUser(); const { permission } = useLoggedInUser();
return path === "/peers" && permission.dashboard_view == "blocked" ? ( return permission.dashboard_view == "blocked" ? (
<>{children}</> <>{children}</>
) : ( ) : (
<GroupsProviderContent>{children}</GroupsProviderContent> <GroupsProviderContent>{children}</GroupsProviderContent>
+7 -4
View File
@@ -20,6 +20,7 @@ const RoutesContext = React.createContext(
toUpdate: Partial<Route>, toUpdate: Partial<Route>,
onSuccess?: (route: Route) => void, onSuccess?: (route: Route) => void,
message?: string, message?: string,
options?: { remove_access_control_groups?: boolean },
) => void; ) => void;
}, },
); );
@@ -33,6 +34,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
toUpdate: Partial<Route>, toUpdate: Partial<Route>,
onSuccess?: (route: Route) => void, onSuccess?: (route: Route) => void,
message?: string, message?: string,
options?: { remove_access_control_groups?: boolean },
) => { ) => {
const hasDomains = route.domains ? route.domains.length > 0 : false; const hasDomains = route.domains ? route.domains.length > 0 : false;
@@ -54,10 +56,11 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
metric: toUpdate.metric ?? route.metric ?? 9999, metric: toUpdate.metric ?? route.metric ?? 9999,
masquerade: toUpdate.masquerade ?? route.masquerade ?? true, masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
groups: toUpdate.groups ?? route.groups ?? [], groups: toUpdate.groups ?? route.groups ?? [],
access_control_groups: access_control_groups: options?.remove_access_control_groups
toUpdate.access_control_groups ?? ? undefined
route.access_control_groups ?? : toUpdate.access_control_groups ??
undefined, route.access_control_groups ??
undefined,
}, },
`/${route.id}`, `/${route.id}`,
) )
+10 -3
View File
@@ -1,8 +1,9 @@
import FullScreenLoading from "@components/ui/FullScreenLoading"; import FullScreenLoading from "@components/ui/FullScreenLoading";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Permission } from "@/interfaces/Permission"; import { Permission } from "@/interfaces/Permission";
import { User } from "@/interfaces/User"; import { Role, User } from "@/interfaces/User";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
@@ -40,8 +41,13 @@ export const useUsers = () => React.useContext(UsersContext);
export const useLoggedInUser = () => { export const useLoggedInUser = () => {
const { loggedInUser } = useUsers(); const { loggedInUser } = useUsers();
const isOwner = loggedInUser ? loggedInUser?.role === "owner" : false; const isOwner = loggedInUser ? loggedInUser?.role === Role.Owner : false;
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false; const isAdmin = loggedInUser ? loggedInUser?.role === Role.Admin : false;
const isBillingAdmin = isNetBirdHosted()
? loggedInUser
? loggedInUser?.role === Role.BillingAdmin
: false
: false;
const isUser = !isOwner && !isAdmin; const isUser = !isOwner && !isAdmin;
const isOwnerOrAdmin = isOwner || isAdmin; const isOwnerOrAdmin = isOwner || isAdmin;
@@ -56,6 +62,7 @@ export const useLoggedInUser = () => {
isOwner, isOwner,
isAdmin, isAdmin,
isUser, isUser,
isBillingAdmin,
isOwnerOrAdmin, isOwnerOrAdmin,
permission, permission,
} as const; } as const;
+1
View File
@@ -35,4 +35,5 @@ export interface GroupedRoute {
description?: string; description?: string;
description_search?: string; description_search?: string;
domain_search?: string; domain_search?: string;
routes_search?: string;
} }
+1
View File
@@ -18,4 +18,5 @@ export enum Role {
User = "user", User = "user",
Admin = "admin", Admin = "admin",
Owner = "owner", Owner = "owner",
BillingAdmin = "billing_admin",
} }
+1
View File
@@ -11,6 +11,7 @@ export default function PageContainer({ children, className }: Props) {
className={cn( className={cn(
className, className,
"relative flex-auto overflow-auto bg-nb-gray z-1", "relative flex-auto overflow-auto bg-nb-gray z-1",
"focus:outline-none",
)} )}
> >
{children} {children}
@@ -46,7 +46,12 @@ export default function AccessTokenActionCell({ access_token }: Props) {
return ( return (
<div className={"flex justify-end pr-4"}> <div className={"flex justify-end pr-4"}>
<Button variant={"danger-outline"} size={"sm"} onClick={handleConfirm}> <Button
variant={"danger-outline"}
size={"sm"}
onClick={handleConfirm}
data-cy={"access-token-delete"}
>
<Trash2 size={16} /> <Trash2 size={16} />
Delete Delete
</Button> </Button>
@@ -98,6 +98,7 @@ export default function CreateAccessTokenModal({ children, user }: Props) {
variant={"secondary"} variant={"secondary"}
className={"w-full"} className={"w-full"}
tabIndex={-1} tabIndex={-1}
data-cy={"access-token-copy-close"}
> >
Close Close
</Button> </Button>
@@ -170,6 +171,7 @@ export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
<Label>Name</Label> <Label>Name</Label>
<HelpText>Set an easily identifiable name for your token</HelpText> <HelpText>Set an easily identifiable name for your token</HelpText>
<Input <Input
data-cy={"access-token-name"}
placeholder={"e.g., Infra token"} placeholder={"e.g., Infra token"}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -184,6 +186,7 @@ export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
<Input <Input
maxWidthClass={"max-w-[200px]"} maxWidthClass={"max-w-[200px]"}
placeholder={"30"} placeholder={"30"}
data-cy={"access-token-expires-in"}
min={1} min={1}
max={365} max={365}
value={expiresIn} value={expiresIn}
@@ -215,7 +218,12 @@ export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>Cancel</Button>
</ModalClose> </ModalClose>
<Button variant={"primary"} onClick={submit} disabled={isDisabled}> <Button
variant={"primary"}
onClick={submit}
disabled={isDisabled}
data-cy={"create-access-token"}
>
<PlusCircle size={16} /> <PlusCircle size={16} />
Create Token Create Token
</Button> </Button>
+8 -1
View File
@@ -1,9 +1,16 @@
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { useMemo } from "react"; import { useMemo } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Account } from "@/interfaces/Account"; import { Account } from "@/interfaces/Account";
export const useAccount = () => { export const useAccount = () => {
const { data: accounts } = useFetchApi<Account[]>("/accounts"); const { isBillingAdmin } = useLoggedInUser();
const { data: accounts } = useFetchApi<Account[]>(
"/accounts",
true,
true,
!isBillingAdmin,
);
return useMemo(() => { return useMemo(() => {
if (!accounts) return; if (!accounts) return;
@@ -0,0 +1,25 @@
import { uniq } from "lodash";
import { useEffect, useMemo, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import type { Group } from "@/interfaces/Group";
export const useGroupIdsToGroups = (initial?: string[]) => {
const { groups, isLoading } = useGroups();
const [initialSet, setInitialSet] = useState(false);
const [mappedGroups, setMappedGroups] = useState<Group[] | undefined>(
undefined,
);
useEffect(() => {
// Only run the mapping once when groups are loaded and initial IDs are available
if (!initialSet && !isLoading && groups && initial) {
const mapped = uniq(initial)
.map((group) => groups.find((g) => g?.id === group))
.filter((g): g is Group => g !== undefined);
setMappedGroups(mapped);
setInitialSet(true); // Mark that we've done the initial mapping to prevent subsequent runs
}
}, [groups, initial, isLoading, initialSet]);
return useMemo(() => mappedGroups, [mappedGroups]);
};
Loaded 20 of 35 files, more files were not shown because too many files have changed in this diff. Show more