Merge pull request #1166 from rahadi23/bugfix/1165-navigation-items-permissions

fix: filter navigation items based on user's permissions and role
This commit is contained in:
Mauricio Siu 2025-01-22 00:24:55 -06:00 committed by GitHub
commit d0608f43a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 451 additions and 334 deletions

View File

@ -118,7 +118,7 @@ export const HandleProject = ({ projectId }: Props) => {
</DialogTrigger>
<DialogContent className="sm:m:max-w-lg ">
<DialogHeader>
<DialogTitle>Add a project</DialogTitle>
<DialogTitle>{projectId ? "Update" : "Add a"} project</DialogTitle>
<DialogDescription>The home of something big!</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

View File

@ -87,9 +87,12 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
<div className="">
<HandleProject />
</div>
{(auth?.rol === "admin" || user?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
)}
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">

View File

@ -1,7 +1,6 @@
"use client";
import {
Activity,
AudioWaveform,
BarChartHorizontalBigIcon,
Bell,
BlocksIcon,
@ -9,7 +8,6 @@ import {
Boxes,
ChevronRight,
CircleHelp,
Command,
CreditCard,
Database,
Folder,
@ -27,8 +25,8 @@ import {
Users,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type * as React from "react";
import { useEffect, useState } from "react";
import {
Breadcrumb,
@ -65,243 +63,290 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { useRouter } from "next/router";
import { Logo } from "../shared/logo";
import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav";
// This is sample data.
interface NavItem {
// The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
type UserQueryOutput = inferRouterOutputs<AppRouter>["user"]["byAuthId"];
type SingleNavItem = {
isSingle?: true;
title: string;
url: string;
icon: LucideIcon;
isSingle: boolean;
isActive: boolean;
items?: {
title: string;
url: string;
icon?: LucideIcon;
}[];
}
icon?: LucideIcon;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
interface ExternalLink {
// NavItem type
// Consists of a single item or a group of items
// If `isSingle` is true or undefined, the item is a single item
// If `isSingle` is false, the item is a group of items
type NavItem =
| SingleNavItem
| {
isSingle: false;
title: string;
icon: LucideIcon;
items: SingleNavItem[];
isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
// ExternalLink type
// Represents an external link item (used for the help section)
type ExternalLink = {
name: string;
url: string;
icon: React.ComponentType<{ className?: string }>;
}
isEnabled?: (opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}) => boolean;
};
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
teams: [
{
name: "Dokploy",
logo: Logo,
plan: "Enterprise",
},
{
name: "Acme Corp.",
logo: AudioWaveform,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
plan: "Free",
},
],
// Menu type
// Consists of home, settings, and help items
type Menu = {
home: NavItem[];
settings: NavItem[];
help: ExternalLink[];
};
// Menu items
// Consists of unfiltered home, settings, and help items
// The items are filtered based on the user's role and permissions
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Projects",
url: "/dashboard/projects",
icon: Folder,
isSingle: true,
isActive: false,
},
{
isSingle: true,
title: "Monitoring",
url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon,
isSingle: true,
isActive: false,
// Only enabled in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => !isCloud,
},
{
isSingle: true,
title: "Traefik File System",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
isSingle: true,
isActive: false,
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(
(auth?.rol === "admin" || user?.canAccessToTraefikFiles) &&
!isCloud
),
},
{
isSingle: true,
title: "Docker",
url: "/dashboard/docker",
icon: BlocksIcon,
isSingle: true,
isActive: false,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
isSingle: true,
isActive: false,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
},
{
isSingle: true,
title: "Requests",
url: "/dashboard/requests",
icon: Forward,
isSingle: true,
isActive: false,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud),
},
// Legacy unused menu, adjusted to the new structure
// {
// isSingle: true,
// title: "Projects",
// url: "/dashboard/projects",
// icon: Folder,
// isSingle: true,
// },
// {
// isSingle: true,
// title: "Monitoring",
// icon: BarChartHorizontalBigIcon,
// url: "/dashboard/settings/monitoring",
// isSingle: true,
// },
// {
// title: "Settings",
// url: "#",
// icon: Settings2,
// isActive: true,
// items: [
// {
// title: "Profile",
// url: "/dashboard/settings/profile",
// },
// {
// title: "Users",
// url: "/dashboard/settings/users",
// },
// {
// title: "SSH Key",
// url: "/dashboard/settings/ssh-keys",
// },
// {
// title: "Git",
// url: "/dashboard/settings/git-providers",
// },
// ],
// isSingle: false,
// title: "Settings",
// icon: Settings2,
// items: [
// {
// title: "Profile",
// url: "/dashboard/settings/profile",
// },
// {
// title: "Users",
// url: "/dashboard/settings/users",
// },
// {
// title: "SSH Key",
// url: "/dashboard/settings/ssh-keys",
// },
// {
// title: "Git",
// url: "/dashboard/settings/git-providers",
// },
// ],
// },
// {
// title: "Integrations",
// icon: BlocksIcon,
// items: [
// {
// title: "S3 Destinations",
// url: "/dashboard/settings/destinations",
// },
// {
// title: "Registry",
// url: "/dashboard/settings/registry",
// },
// {
// title: "Notifications",
// url: "/dashboard/settings/notifications",
// },
// ],
] as NavItem[],
// isSingle: false,
// title: "Integrations",
// icon: BlocksIcon,
// items: [
// {
// title: "S3 Destinations",
// url: "/dashboard/settings/destinations",
// },
// {
// title: "Registry",
// url: "/dashboard/settings/registry",
// },
// {
// title: "Notifications",
// url: "/dashboard/settings/notifications",
// },
// ],
// },
],
settings: [
{
isSingle: true,
title: "Server",
url: "/dashboard/settings/server",
icon: Activity,
isSingle: true,
isActive: false,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && !isCloud),
},
{
isSingle: true,
title: "Profile",
url: "/dashboard/settings/profile",
icon: User,
isSingle: true,
isActive: false,
},
{
isSingle: true,
title: "Servers",
url: "/dashboard/settings/servers",
icon: Server,
isSingle: true,
isActive: false,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
},
{
isSingle: true,
title: "Users",
icon: Users,
url: "/dashboard/settings/users",
isSingle: true,
isActive: false,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
},
{
isSingle: true,
title: "SSH Keys",
icon: KeyRound,
url: "/dashboard/settings/ssh-keys",
isSingle: true,
isActive: false,
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth, user }) =>
!!(auth?.rol === "admin" || user?.canAccessToSSHKeys),
},
{
isSingle: true,
title: "Git",
url: "/dashboard/settings/git-providers",
icon: GitBranch,
isSingle: true,
isActive: false,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth, user }) =>
!!(auth?.rol === "admin" || user?.canAccessToGitProviders),
},
{
isSingle: true,
title: "Registry",
url: "/dashboard/settings/registry",
icon: Package,
isSingle: true,
isActive: false,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
},
{
isSingle: true,
title: "S3 Destinations",
url: "/dashboard/settings/destinations",
icon: Database,
isSingle: true,
isActive: false,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
},
{
isSingle: true,
title: "Certificates",
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
isSingle: true,
isActive: false,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
},
{
isSingle: true,
title: "Cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
isSingle: true,
isActive: false,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && !isCloud),
},
{
isSingle: true,
title: "Notifications",
url: "/dashboard/settings/notifications",
icon: Bell,
isSingle: true,
isActive: false,
// Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"),
},
{
isSingle: true,
title: "Billing",
url: "/dashboard/settings/billing",
icon: CreditCard,
isSingle: true,
isActive: false,
// Only enabled for admins in cloud environments
isEnabled: ({ auth, user, isCloud }) =>
!!(auth?.rol === "admin" && isCloud),
},
] as NavItem[],
],
help: [
{
name: "Documentation",
@ -325,8 +370,108 @@ const data = {
/>
),
},
] as ExternalLink[],
};
],
} as const;
/**
* Creates a menu based on the current user's role and permissions
* @returns a menu object with the home, settings, and help items
*/
function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean;
}): Menu {
return {
// Filter the home items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
home: MENU.home.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud,
}),
),
// Filter the settings items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
settings: MENU.settings.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud,
}),
),
// Filter the help items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
help: MENU.help.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud,
}),
),
};
}
/**
* Determines if an item url is active based on the current pathname
* @returns true if the item url is active, false otherwise
*/
function isActiveRoute(opts: {
/** The url of the item. Usually obtained from `item.url` */
itemUrl: string;
/** The current pathname. Usually obtained from `usePathname()` */
pathname: string;
}): boolean {
const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project");
const normalizedPathname = opts.pathname?.replace("/projects", "/project");
if (!normalizedPathname) return false;
if (normalizedPathname === normalizedItemUrl) return true;
if (normalizedPathname.startsWith(normalizedItemUrl)) {
const nextChar = normalizedPathname.charAt(normalizedItemUrl.length);
return nextChar === "/";
}
return false;
}
/**
* Finds the active nav item based on the current pathname
* @returns the active nav item with `SingleNavItem` type or undefined if none is active
*/
function findActiveNavItem(
navItems: NavItem[],
pathname: string,
): SingleNavItem | undefined {
const found = navItems.find((item) =>
item.isSingle !== false
? // The current item is single, so check if the item url is active
isActiveRoute({ itemUrl: item.url, pathname })
: // The current item is not single, so check if any of the sub items are active
item.items.some((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
),
);
if (found?.isSingle !== false) {
// The found item is single, so return it
return found;
}
// The found item is not single, so find the active sub item
return found?.items.find((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
);
}
interface Props {
children: React.ReactNode;
@ -398,64 +543,21 @@ export default function Page({ children }: Props) {
const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
const isActiveRoute = (itemUrl: string) => {
const normalizedItemUrl = itemUrl?.replace("/projects", "/project");
const normalizedPathname = pathname?.replace("/projects", "/project");
if (!normalizedPathname) return false;
const {
home: filteredHome,
settings: filteredSettings,
help,
} = createMenuForAuthUser({ auth, user, isCloud: !!isCloud });
if (normalizedPathname === normalizedItemUrl) return true;
const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings],
pathname,
);
if (normalizedPathname.startsWith(normalizedItemUrl)) {
const nextChar = normalizedPathname.charAt(normalizedItemUrl.length);
return nextChar === "/";
}
return false;
};
let filteredHome = isCloud
? data.home.filter(
(item) =>
![
"/dashboard/monitoring",
"/dashboard/traefik",
"/dashboard/docker",
"/dashboard/swarm",
"/dashboard/requests",
].includes(item.url),
)
: data.home;
let filteredSettings = isCloud
? data.settings.filter(
(item) =>
![
"/dashboard/settings/server",
"/dashboard/settings/cluster",
].includes(item.url),
)
: data.settings.filter(
(item) => !["/dashboard/settings/billing"].includes(item.url),
);
filteredHome = filteredHome.map((item) => ({
...item,
isActive: isActiveRoute(item.url),
}));
filteredSettings = filteredSettings.map((item) => ({
...item,
isActive: isActiveRoute(item.url),
}));
const activeItem =
filteredHome.find((item) => item.isActive) ||
filteredSettings.find((item) => item.isActive);
const showProjectsButton =
currentPath === "/dashboard/projects" &&
(auth?.rol === "admin" || user?.canCreateProjects);
// const showProjectsButton =
// currentPath === "/dashboard/projects" &&
// (auth?.rol === "admin" || user?.canCreateProjects);
return (
<SidebarProvider
@ -486,173 +588,185 @@ export default function Page({ children }: Props) {
<SidebarGroup>
<SidebarGroupLabel>Home</SidebarGroupLabel>
<SidebarMenu>
{filteredHome.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{item.isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActiveRoute(item.url) && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
<item.icon
className={cn(
isActiveRoute(item.url) && "text-primary",
)}
/>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={item.isActive}
>
{item.icon && <item.icon />}
{filteredHome.map((item) => {
const isSingle = item.isSingle !== false;
const isActive = isSingle
? isActiveRoute({ itemUrl: item.url, pathname })
: item.items.some((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
);
<span>{item.title}</span>
{item.items?.length && (
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
return (
<Collapsible
key={item.title}
asChild
defaultOpen={isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActive && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
/>
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(
isActiveRoute(subItem.url) && "bg-border",
)}
>
<Link
href={subItem.url}
className="flex w-full items-center"
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={isActive}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.items?.length && (
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(isActive && "bg-border")}
>
{subItem.icon && (
<span className="mr-2">
<subItem.icon
className={cn(
"h-4 w-4 text-muted-foreground",
isActiveRoute(subItem.url) &&
"text-primary",
)}
/>
</span>
)}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
))}
<Link
href={subItem.url}
className="flex w-full items-center"
>
{subItem.icon && (
<span className="mr-2">
<subItem.icon
className={cn(
"h-4 w-4 text-muted-foreground",
isActive && "text-primary",
)}
/>
</span>
)}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarMenu className="gap-2">
{filteredSettings.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{item.isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActiveRoute(item.url) && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
<item.icon
className={cn(
isActiveRoute(item.url) && "text-primary",
)}
/>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={item.isActive}
>
{item.icon && <item.icon />}
{filteredSettings.map((item) => {
const isSingle = item.isSingle !== false;
const isActive = isSingle
? isActiveRoute({ itemUrl: item.url, pathname })
: item.items.some((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
);
<span>{item.title}</span>
{item.items?.length && (
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
return (
<Collapsible
key={item.title}
asChild
defaultOpen={isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{isSingle ? (
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(isActive && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
/>
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(
isActiveRoute(subItem.url) && "bg-border",
)}
>
<Link
href={subItem.url}
className="flex w-full items-center"
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={isActive}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.items?.length && (
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(isActive && "bg-border")}
>
{subItem.icon && (
<span className="mr-2">
<subItem.icon
className={cn(
"h-4 w-4 text-muted-foreground",
isActiveRoute(subItem.url) &&
"text-primary",
)}
/>
</span>
)}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
))}
<Link
href={subItem.url}
className="flex w-full items-center"
>
{subItem.icon && (
<span className="mr-2">
<subItem.icon
className={cn(
"h-4 w-4 text-muted-foreground",
isActive && "text-primary",
)}
/>
</span>
)}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Extra</SidebarGroupLabel>
<SidebarMenu>
{data.help.map((item: ExternalLink) => (
{help.map((item: ExternalLink) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a

View File

@ -1,7 +1,7 @@
export const Languages = {
english: { code: "en", name: "English" },
polish: { code: "pl", name: "Polski" },
ukrainian: {code: 'uk', name: "Українська"},
ukrainian: { code: "uk", name: "Українська" },
russian: { code: "ru", name: "Русский" },
french: { code: "fr", name: "Français" },
german: { code: "de", name: "Deutsch" },

View File

@ -55,4 +55,4 @@
"settings.terminal.ipAddress": "IP-адреса",
"settings.terminal.port": "Порт",
"settings.terminal.username": "Ім'я користувача"
}
}