mirror of
https://github.com/portainer/portainer.git
synced 2025-08-08 23:35:31 +02:00
refactor(sidebar): migrate sidebar to react [EE-2907] (#6725)
* refactor(sidebar): migrate sidebar to react [EE-2907] fixes [EE-2907] feat(sidebar): show label for help fix(sidebar): apply changes from ddExtension fix(sidebar): resolve conflicts style(ts): add explanation for ddExtension fix(sidebar): use enum for status refactor(sidebar): rename to EdgeComputeSidebar refactor(sidebar): removed the need of `ident` prop style(sidebar): add ref for mobile breakpoint refactor(app): document testing props refactor(sidebar): use single sidebar item refactor(sidebar): use section for nav refactor(sidebar): rename sidebarlink to link refactor(sidebar): memoize menu paths fix(kubectl-shell): infinite loop on hooks dependencies refactor(sidebar): use authorized element feat(k8s/shell): track open shell refactor(k8s/shell): remove memoization refactor(settings): move settings queries to queries fix(sidebar): close sidebar on mobile refactor(settings): use mutation helpers refactor(sidebar): remove memo refactor(sidebar): rename sidebar item for storybook refactor(sidebar): move to react gprefactor(sidebar): remove dependence on EndProvider feat(environments): rename settings type feat(kube): move kubeconfig button fix(sidebar): open submenus fix(sidebar): open on expand fix(sibebar): show kube shell correctly * fix(sidebar): import from react component * chore(tests): fix missing prop
This commit is contained in:
parent
f78a6568a6
commit
84611a90a1
118 changed files with 2284 additions and 1648 deletions
|
@ -10,6 +10,7 @@ import teamsModule from './teams';
|
|||
import homeModule from './home';
|
||||
import { accessControlModule } from './access-control';
|
||||
import { reactModule } from './react';
|
||||
import { sidebarModule } from './react/views/sidebar';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
|
@ -40,6 +41,7 @@ angular
|
|||
teamsModule,
|
||||
accessControlModule,
|
||||
reactModule,
|
||||
sidebarModule,
|
||||
])
|
||||
.config([
|
||||
'$stateRegistryProvider',
|
||||
|
@ -68,8 +70,7 @@ angular
|
|||
},
|
||||
views: {
|
||||
'sidebar@': {
|
||||
templateUrl: './views/sidebar/sidebar.html',
|
||||
controller: 'SidebarController',
|
||||
component: 'sidebar',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -106,9 +106,9 @@ export function AccessControlPanel({
|
|||
function useRestrictions(resourceControl?: ResourceControlViewModel) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const memberships = useUserMembership(user?.Id);
|
||||
const memberships = useUserMembership(user.Id);
|
||||
|
||||
if (!resourceControl || isAdmin || !user) {
|
||||
if (!resourceControl || isAdmin) {
|
||||
return {
|
||||
isPartOfRestrictedUsers: false,
|
||||
isLeaderOfAnyRestrictedTeams: false,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import formComponentsModule from './form-components';
|
||||
import sidebarModule from './sidebar';
|
||||
import gitFormModule from './forms/git-form';
|
||||
import porAccessManagementModule from './accessManagement';
|
||||
import widgetModule from './widget';
|
||||
|
@ -13,7 +12,7 @@ import { beFeatureIndicator } from './BEFeatureIndicator';
|
|||
import { InformationPanelAngular } from './InformationPanel';
|
||||
|
||||
export default angular
|
||||
.module('portainer.app.components', [pageHeaderModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
|
||||
.module('portainer.app.components', [pageHeaderModule, boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule])
|
||||
.component('informationPanel', InformationPanelAngular)
|
||||
|
||||
.component('portainerTooltip', TooltipAngular)
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
.sidebar-menu-item > a {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { SidebarMenuItem } from './SidebarMenuItem';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/SidebarMenuItem',
|
||||
component: SidebarMenuItem,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
interface StoryProps {
|
||||
iconClass?: string;
|
||||
className: string;
|
||||
itemName: string;
|
||||
linkName: string;
|
||||
}
|
||||
|
||||
function Template({ iconClass, className, itemName, linkName }: StoryProps) {
|
||||
return (
|
||||
<ul className="sidebar">
|
||||
<div className="sidebar-list">
|
||||
<SidebarMenuItem
|
||||
path="example.path"
|
||||
pathParams={{ endpointId: 1 }}
|
||||
iconClass={iconClass}
|
||||
className={className}
|
||||
itemName={itemName}
|
||||
>
|
||||
{linkName}
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
iconClass: 'fa-tachometer-alt fa-fw',
|
||||
className: 'exampleItemClass',
|
||||
itemName: 'ExampleItem',
|
||||
linkName: 'Item with icon',
|
||||
};
|
||||
|
||||
export const WithoutIcon: Story<StoryProps> = Template.bind({});
|
||||
WithoutIcon.args = {
|
||||
className: 'exampleItemClass',
|
||||
itemName: 'ExampleItem',
|
||||
linkName: 'Item without icon',
|
||||
};
|
|
@ -1,51 +0,0 @@
|
|||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
import { SidebarMenuItem } from './SidebarMenuItem';
|
||||
|
||||
test('should be visible & have expected class', () => {
|
||||
const { getByLabelText } = renderComponent('testClass');
|
||||
const listItem = getByLabelText('sidebarItem');
|
||||
expect(listItem).toBeVisible();
|
||||
expect(listItem).toHaveClass('testClass');
|
||||
});
|
||||
|
||||
test('icon should with correct icon if iconClass is provided', () => {
|
||||
const { getByLabelText } = renderComponent('', 'testIconClass');
|
||||
const sidebarIcon = getByLabelText('itemIcon');
|
||||
expect(sidebarIcon).toBeVisible();
|
||||
expect(sidebarIcon).toHaveClass('testIconClass');
|
||||
});
|
||||
|
||||
test('icon should not be rendered if iconClass is not provided', () => {
|
||||
const { queryByLabelText } = renderComponent();
|
||||
expect(queryByLabelText('itemIcon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render children', () => {
|
||||
const { getByLabelText } = renderComponent('', '', 'Test');
|
||||
expect(getByLabelText('sidebarItem')).toHaveTextContent('Test');
|
||||
});
|
||||
|
||||
test('li element should have correct accessibility label', () => {
|
||||
const { queryByLabelText } = renderComponent('', '', '', 'testItemLabel');
|
||||
expect(queryByLabelText('testItemLabel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function renderComponent(
|
||||
className = '',
|
||||
iconClass = '',
|
||||
linkText = '',
|
||||
itemName = 'sidebarItem'
|
||||
) {
|
||||
return render(
|
||||
<SidebarMenuItem
|
||||
path=""
|
||||
pathParams={{ endpointId: 1 }}
|
||||
iconClass={iconClass}
|
||||
className={className}
|
||||
itemName={itemName}
|
||||
>
|
||||
{linkText}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { UISrefActive } from '@uirouter/react';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import '../sidebar.css';
|
||||
import styles from './SidebarMenuItem.module.css';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
pathParams: object;
|
||||
iconClass?: string;
|
||||
className: string;
|
||||
itemName: string;
|
||||
}
|
||||
|
||||
export function SidebarMenuItem({
|
||||
path,
|
||||
pathParams,
|
||||
iconClass,
|
||||
className,
|
||||
itemName,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<li
|
||||
className={clsx('sidebar-menu-item', styles.sidebarMenuItem, className)}
|
||||
aria-label={itemName}
|
||||
>
|
||||
<UISrefActive class="active">
|
||||
<Link to={path} params={pathParams} title={itemName}>
|
||||
{children}
|
||||
{iconClass && (
|
||||
<i
|
||||
className={clsx('menu-icon fa', iconClass)}
|
||||
aria-label="itemIcon"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</UISrefActive>
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { SidebarMenuItem } from './SidebarMenuItem';
|
|
@ -1,12 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import './sidebar.css';
|
||||
|
||||
import { sidebarMenu } from './sidebar-menu';
|
||||
import { sidebarSection } from './sidebar-section';
|
||||
import { sidebarMenuItem } from './sidebar-menu-item';
|
||||
|
||||
export default angular
|
||||
.module('portainer.app.components.sidebar', [])
|
||||
.component('sidebarMenu', sidebarMenu)
|
||||
.component('sidebarMenuItem', sidebarMenuItem)
|
||||
.component('sidebarSection', sidebarSection).name;
|
|
@ -1,12 +0,0 @@
|
|||
import './sidebar-menu-item.css';
|
||||
|
||||
export const sidebarMenuItem = {
|
||||
templateUrl: './sidebar-menu-item.html',
|
||||
bindings: {
|
||||
path: '@',
|
||||
pathParams: '<',
|
||||
iconClass: '@',
|
||||
className: '@',
|
||||
},
|
||||
transclude: true,
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
.sidebar-menu-item > a {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
<li class="sidebar-menu-item" ng-class="$ctrl.className">
|
||||
<a ui-state="$ctrl.path" ui-state-params="$ctrl.pathParams" ui-sref-active="active">
|
||||
<ng-transclude></ng-transclude>
|
||||
<span ng-if="$ctrl.iconClass" class="menu-icon fa" ng-class="$ctrl.iconClass"></span>
|
||||
</a>
|
||||
</li>
|
|
@ -1,18 +0,0 @@
|
|||
import './sidebar-menu.css';
|
||||
|
||||
import controller from './sidebar-menu.controller.js';
|
||||
|
||||
export const sidebarMenu = {
|
||||
templateUrl: './sidebar-menu.html',
|
||||
controller,
|
||||
bindings: {
|
||||
iconClass: '@',
|
||||
path: '@', // string
|
||||
pathParams: '<', //object, corresponds to https://ui-router.github.io/ng1/docs/latest/modules/directives.html#uistatedirective
|
||||
childrenPaths: '<', // []string (globs)
|
||||
label: '@', // string
|
||||
isSidebarOpen: '<',
|
||||
currentState: '@',
|
||||
},
|
||||
transclude: true,
|
||||
};
|
|
@ -1,45 +0,0 @@
|
|||
class SidebarMenuController {
|
||||
/* @ngInject */
|
||||
constructor($state) {
|
||||
this.$state = $state;
|
||||
|
||||
this.state = {
|
||||
forceOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
if (!this.isSidebarOpen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.state.forceOpen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.isOpenByPathState();
|
||||
}
|
||||
|
||||
isOpenByPathState() {
|
||||
const currentName = this.$state.current.name;
|
||||
return currentName.startsWith(this.path) || this.childrenPaths.includes(currentName);
|
||||
}
|
||||
|
||||
onClickArrow(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// prevent toggling when menu is open by state
|
||||
if (this.isOpenByPathState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.forceOpen = !this.state.forceOpen;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.childrenPaths = this.childrenPaths || [];
|
||||
}
|
||||
}
|
||||
|
||||
export default SidebarMenuController;
|
|
@ -1,15 +0,0 @@
|
|||
.sidebar-menu .sidebar-menu-head {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-menu .sidebar-menu-head .sidebar-menu-indicator {
|
||||
background: none;
|
||||
border: 0;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
color: white;
|
||||
margin: 0 10px;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
<li class="sidebar-list sidebar-menu">
|
||||
<sidebar-menu-item
|
||||
path="{{::$ctrl.path }}"
|
||||
path-params="$ctrl.pathParams"
|
||||
icon-class="{{::$ctrl.iconClass}}"
|
||||
data-cy="portainerSidebar-{{ ::$ctrl.label }}"
|
||||
title="{{ ::$ctrl.label }}"
|
||||
>
|
||||
<div class="sidebar-menu-head">
|
||||
<button ng-click="$ctrl.onClickArrow($event)" class="small sidebar-menu-indicator">
|
||||
<i class="fas" ng-class="$ctrl.isOpen() ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
|
||||
</button>
|
||||
{{ ::$ctrl.label }}
|
||||
</div>
|
||||
</sidebar-menu-item>
|
||||
|
||||
<div class="sidebar-list-items" ng-if="$ctrl.isOpen()">
|
||||
<ng-transclude></ng-transclude>
|
||||
</div>
|
||||
</li>
|
|
@ -1,7 +0,0 @@
|
|||
export const sidebarSection = {
|
||||
templateUrl: './sidebar-section.html',
|
||||
transclude: true,
|
||||
bindings: {
|
||||
title: '@',
|
||||
},
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
<div class="sidebar-section">
|
||||
<li class="sidebar-title">
|
||||
<span>{{ ::$ctrl.title }}</span>
|
||||
</li>
|
||||
|
||||
<div class="sidebar-section-items" ng-transclude> </div>
|
||||
</div>
|
|
@ -1,327 +0,0 @@
|
|||
#page-wrapper.open #sidebar-wrapper {
|
||||
left: 150px;
|
||||
}
|
||||
|
||||
/* #592727 RED */
|
||||
/* #2f5927 GREEN */
|
||||
/* #30426a BLUE (default)*/
|
||||
/* Sidebar background color */
|
||||
/* Sidebar header and footer color */
|
||||
/* Sidebar title text colour */
|
||||
/* Sidebar menu item hover color */
|
||||
/**
|
||||
* Sidebar
|
||||
*/
|
||||
#sidebar-wrapper {
|
||||
background: var(--bg-sidebar-wrapper-color);
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-main a,
|
||||
.sidebar-footer,
|
||||
ul.sidebar .sidebar-list a:hover,
|
||||
#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator {
|
||||
/* Sidebar header and footer color */
|
||||
background: var(--hover-sidebar-color);
|
||||
}
|
||||
|
||||
ul.sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
text-indent: 20px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ul.sidebar li a {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-main {
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-main a {
|
||||
font-size: 18px;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-main a:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-main .menu-icon {
|
||||
float: right;
|
||||
font-size: 18px;
|
||||
padding-right: 28px;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title {
|
||||
color: var(--text-sidebar-title-color);
|
||||
font-size: 12px;
|
||||
height: 35px;
|
||||
line-height: 40px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.6s ease 0s;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a {
|
||||
text-indent: 25px;
|
||||
font-size: 15px;
|
||||
color: var(--text-sidebar-list-color);
|
||||
line-height: 40px;
|
||||
padding-left: 5px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a:hover {
|
||||
color: #fff;
|
||||
border-left-color: #e99d1a;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a:hover .menu-icon {
|
||||
text-indent: 25px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .menu-icon {
|
||||
padding-right: 30px;
|
||||
line-height: 40px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) ul.sidebar {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) ul.sidebar .sidebar-title {
|
||||
display: none;
|
||||
height: 0px;
|
||||
text-indent: -100px;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator {
|
||||
display: block;
|
||||
height: 2px;
|
||||
margin: 13px 0;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) ul.sidebar .sidebar-list a:hover span {
|
||||
border-left: 3px solid #e99d1a;
|
||||
text-indent: 22px;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) .sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
position: absolute;
|
||||
height: 40px;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: all 0.6s ease 0s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer div a {
|
||||
color: var(--text-sidebar-list-color);
|
||||
font-size: 12px;
|
||||
line-height: 43px;
|
||||
}
|
||||
|
||||
.sidebar-footer div a:hover {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* #592727 RED */
|
||||
/* #2f5927 GREEN */
|
||||
/* #30426a BLUE (default)*/
|
||||
/* Sidebar background color */
|
||||
/* Sidebar header and footer color */
|
||||
/* Sidebar title text colour */
|
||||
/* Sidebar menu item hover color */
|
||||
|
||||
ul.sidebar {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title.endpoint-name {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a.active {
|
||||
color: #fff;
|
||||
border-left-color: var(--border-sidebar-color);
|
||||
background: var(--hover-sidebar-color);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: 60px;
|
||||
list-style: none;
|
||||
text-indent: 20px;
|
||||
font-size: 18px;
|
||||
background: var(--bg-sidebar-header-color);
|
||||
}
|
||||
|
||||
.sidebar-header a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-header a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-header .menu-icon {
|
||||
float: right;
|
||||
padding-right: 28px;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) .sidebar-footer-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-footer-content .logo {
|
||||
width: 100%;
|
||||
max-width: 100px;
|
||||
height: 100%;
|
||||
max-height: 35px;
|
||||
margin: 2px 0 2px 20px;
|
||||
}
|
||||
|
||||
.sidebar-footer-content .update-notification {
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 2px;
|
||||
background-color: #ff851b;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.sidebar-footer-content .version {
|
||||
font-size: 11px;
|
||||
margin: 11px 20px 0 7px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-footer-content .edition-version {
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#sidebar-wrapper {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a.active .menu-icon {
|
||||
text-indent: 25px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .sidebar-sublist a {
|
||||
text-indent: 35px;
|
||||
font-size: 12px;
|
||||
color: var(--text-sidebar-list-color);
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title .form-control {
|
||||
height: 36px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a,
|
||||
ul.sidebar .sidebar-list .sidebar-sublist a {
|
||||
line-height: 36px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .menu-icon {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .sidebar-sublist a.active {
|
||||
color: #fff;
|
||||
border-left: 3px solid #fff;
|
||||
background: var(--bg-sidebar-color);
|
||||
}
|
||||
|
||||
@media (max-height: 785px) {
|
||||
ul.sidebar .sidebar-title {
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title .form-control {
|
||||
height: 26px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a,
|
||||
ul.sidebar .sidebar-list .sidebar-sublist a {
|
||||
font-size: 12px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .menu-icon {
|
||||
line-height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 786px) and (max-height: 924px) {
|
||||
ul.sidebar .sidebar-title {
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-title .form-control {
|
||||
height: 30px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list a,
|
||||
ul.sidebar .sidebar-list .sidebar-sublist a {
|
||||
font-size: 12px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
ul.sidebar .sidebar-list .menu-icon {
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ import type {
|
|||
Environment,
|
||||
EnvironmentId,
|
||||
EnvironmentType,
|
||||
EnvironmentSettings,
|
||||
EnvironmentSecuritySettings,
|
||||
EnvironmentStatus,
|
||||
} from '../types';
|
||||
|
||||
|
@ -239,7 +239,7 @@ export async function forceUpdateService(
|
|||
|
||||
export async function updateSettings(
|
||||
id: EnvironmentId,
|
||||
settings: EnvironmentSettings
|
||||
settings: EnvironmentSecuritySettings
|
||||
) {
|
||||
try {
|
||||
await axios.put(buildUrl(id, 'settings'), settings);
|
||||
|
@ -247,14 +247,3 @@ export async function updateSettings(
|
|||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function trustEndpoint(id: EnvironmentId) {
|
||||
try {
|
||||
const { data: endpoint } = await axios.put<Environment>(buildUrl(id), {
|
||||
UserTrusted: true,
|
||||
});
|
||||
return endpoint;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to update environment');
|
||||
}
|
||||
}
|
||||
|
|
13
app/portainer/environments/queries/useEnvironment.ts
Normal file
13
app/portainer/environments/queries/useEnvironment.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getEndpoint } from '@/portainer/environments/environment.service';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
export function useEnvironment(id?: EnvironmentId) {
|
||||
return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), {
|
||||
...withError('Failed loading environment'),
|
||||
staleTime: 50,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
|
@ -61,6 +61,27 @@ export type EnvironmentEdge = {
|
|||
CommandInterval: number;
|
||||
};
|
||||
|
||||
export interface EnvironmentSecuritySettings {
|
||||
// Whether non-administrator should be able to use bind mounts when creating containers
|
||||
allowBindMountsForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use privileged mode when creating containers
|
||||
allowPrivilegedModeForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to browse volumes
|
||||
allowVolumeBrowserForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use the host pid
|
||||
allowHostNamespaceForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use device mapping
|
||||
allowDeviceMappingForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to manage stacks
|
||||
allowStackManagementForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use container capabilities
|
||||
allowContainerCapabilitiesForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
allowSysctlSettingForRegularUsers: boolean;
|
||||
// Whether host management features are enabled
|
||||
enableHostManagementFeatures: boolean;
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
Id: EnvironmentId;
|
||||
Type: EnvironmentType;
|
||||
|
@ -81,6 +102,7 @@ export type Environment = {
|
|||
UserTrusted: boolean;
|
||||
AMTDeviceGUID?: string;
|
||||
Edge: EnvironmentEdge;
|
||||
SecuritySettings: EnvironmentSecuritySettings;
|
||||
};
|
||||
/**
|
||||
* TS reference of endpoint_create.go#EndpointCreationType iota
|
||||
|
@ -99,24 +121,3 @@ export enum PlatformType {
|
|||
Kubernetes,
|
||||
Azure,
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
// Whether non-administrator should be able to use bind mounts when creating containers
|
||||
allowBindMountsForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use privileged mode when creating containers
|
||||
allowPrivilegedModeForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to browse volumes
|
||||
allowVolumeBrowserForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use the host pid
|
||||
allowHostNamespaceForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use device mapping
|
||||
allowDeviceMappingForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to manage stacks
|
||||
allowStackManagementForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use container capabilities
|
||||
allowContainerCapabilitiesForRegularUsers: boolean;
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
allowSysctlSettingForRegularUsers: boolean;
|
||||
// Whether host management features are enabled
|
||||
enableHostManagementFeatures: boolean;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export function getPlatformType(envType: EnvironmentType) {
|
|||
case EnvironmentType.Azure:
|
||||
return PlatformType.Azure;
|
||||
default:
|
||||
throw new Error(`Environment Type ${envType} is not supported`);
|
||||
throw new Error(`${envType} is not a supported environment type`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,14 +10,11 @@ import { Button } from '@@/buttons';
|
|||
import { KubeconfigPrompt } from './KubeconfigPrompt';
|
||||
import '@reach/dialog/styles.css';
|
||||
|
||||
export interface KubeconfigButtonProps {
|
||||
export interface Props {
|
||||
environments: Environment[];
|
||||
envQueryParams: EnvironmentsQueryParams;
|
||||
}
|
||||
export function KubeconfigButton({
|
||||
environments,
|
||||
envQueryParams,
|
||||
}: KubeconfigButtonProps) {
|
||||
export function KubeconfigButton({ environments, envQueryParams }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!environments) {
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { DialogOverlay } from '@reach/dialog';
|
||||
|
||||
import * as kcService from '@/kubernetes/services/kubeconfig.service';
|
||||
|
@ -8,6 +7,7 @@ import { EnvironmentType } from '@/portainer/environments/types';
|
|||
import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service/index';
|
||||
import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState';
|
||||
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
@ -29,10 +29,11 @@ export function KubeconfigPrompt({
|
|||
}: KubeconfigPromptProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
||||
const kubeServiceExpiryQuery = useQuery(['kubeServiceExpiry'], async () => {
|
||||
const expiryMessage = await kcService.expiryMessage();
|
||||
return expiryMessage;
|
||||
});
|
||||
|
||||
const expiryQuery = usePublicSettings((settings) =>
|
||||
expiryMessage(settings.KubeconfigExpiry)
|
||||
);
|
||||
|
||||
const { selection, toggle: toggleSelection, selectionSize } = useSelection();
|
||||
const { environments, totalCount } = useEnvironmentList({
|
||||
...envQueryParams,
|
||||
|
@ -67,9 +68,7 @@ export function KubeconfigPrompt({
|
|||
Select the kubernetes environments to add to the kubeconfig
|
||||
file. You may select across multiple pages.
|
||||
</span>
|
||||
<span className="space-left">
|
||||
{kubeServiceExpiryQuery.data}
|
||||
</span>
|
||||
<span className="space-left">{expiryQuery.data}</span>
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
|
@ -140,3 +139,20 @@ export function KubeconfigPrompt({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function expiryMessage(expiry: string) {
|
||||
const prefix = 'Kubeconfig file will';
|
||||
switch (expiry) {
|
||||
case '24h':
|
||||
return `${prefix} expire in 1 day.`;
|
||||
case '168h':
|
||||
return `${prefix} expire in 7 days.`;
|
||||
case '720h':
|
||||
return `${prefix} expire in 30 days.`;
|
||||
case '8640h':
|
||||
return `${prefix} expire in 1 year.`;
|
||||
case '0':
|
||||
default:
|
||||
return `${prefix} not expire.`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { KubeconfigButton } from './KubeconfigButton';
|
|
@ -7,10 +7,14 @@ import {
|
|||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import { isAdmin } from '@/portainer/users/user.helpers';
|
||||
|
||||
import { getUser } from '../users/user.service';
|
||||
import { User, UserId } from '../users/types';
|
||||
import { EnvironmentId } from '../environments/types';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
|
@ -27,9 +31,17 @@ export function useUser() {
|
|||
throw new Error('should be nested under UserProvider');
|
||||
}
|
||||
|
||||
const { user } = context;
|
||||
if (typeof user === 'undefined') {
|
||||
throw new Error('should be authenticated');
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({ user: context.user, isAdmin: isAdmin(context.user) }),
|
||||
[context.user]
|
||||
() => ({
|
||||
user,
|
||||
isAdmin: isAdmin(user),
|
||||
}),
|
||||
[user]
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -37,22 +49,49 @@ export function useAuthorizations(
|
|||
authorizations: string | string[],
|
||||
adminOnlyCE = false
|
||||
) {
|
||||
const authorizationsArray =
|
||||
typeof authorizations === 'string' ? [authorizations] : authorizations;
|
||||
|
||||
const { user } = useUser();
|
||||
const { params } = useCurrentStateAndParams();
|
||||
const {
|
||||
params: { endpointId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasAuthorizations(user, authorizations, endpointId, adminOnlyCE);
|
||||
}
|
||||
|
||||
export function isEnvironmentAdmin(
|
||||
user: User,
|
||||
environmentId: EnvironmentId,
|
||||
adminOnlyCE = false
|
||||
) {
|
||||
return hasAuthorizations(
|
||||
user,
|
||||
['EndpointResourcesAccess'],
|
||||
environmentId,
|
||||
adminOnlyCE
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAuthorizations(
|
||||
user: User,
|
||||
authorizations: string | string[],
|
||||
environmentId?: EnvironmentId,
|
||||
adminOnlyCE = false
|
||||
) {
|
||||
const authorizationsArray =
|
||||
typeof authorizations === 'string' ? [authorizations] : authorizations;
|
||||
|
||||
if (authorizationsArray.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.env.PORTAINER_EDITION === 'CE') {
|
||||
return !adminOnlyCE || isAdmin(user);
|
||||
}
|
||||
|
||||
const { endpointId } = params;
|
||||
if (!endpointId) {
|
||||
if (!environmentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -62,12 +101,12 @@ export function useAuthorizations(
|
|||
|
||||
if (
|
||||
!user.EndpointAuthorizations ||
|
||||
!user.EndpointAuthorizations[endpointId]
|
||||
!user.EndpointAuthorizations[environmentId]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userEndpointAuthorizations = user.EndpointAuthorizations[endpointId];
|
||||
const userEndpointAuthorizations = user.EndpointAuthorizations[environmentId];
|
||||
return authorizationsArray.some(
|
||||
(authorization) => userEndpointAuthorizations[authorization]
|
||||
);
|
||||
|
@ -75,11 +114,15 @@ export function useAuthorizations(
|
|||
|
||||
interface AuthorizedProps {
|
||||
authorizations: string | string[];
|
||||
children: ReactNode;
|
||||
adminOnlyCE?: boolean;
|
||||
}
|
||||
|
||||
export function Authorized({ authorizations, children }: AuthorizedProps) {
|
||||
const isAllowed = useAuthorizations(authorizations);
|
||||
export function Authorized({
|
||||
authorizations,
|
||||
adminOnlyCE = false,
|
||||
children,
|
||||
}: PropsWithChildren<AuthorizedProps>) {
|
||||
const isAllowed = useAuthorizations(authorizations, adminOnlyCE);
|
||||
|
||||
return isAllowed ? <>{children}</> : null;
|
||||
}
|
||||
|
@ -90,7 +133,7 @@ interface UserProviderProps {
|
|||
|
||||
export function UserProvider({ children }: UserProviderProps) {
|
||||
const [jwt] = useLocalStorage('JWT', '');
|
||||
const [user, setUser] = useState<User | undefined>();
|
||||
const [user, setUser] = useState<User>();
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt !== '') {
|
||||
|
@ -106,7 +149,7 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (providerState.user === null) {
|
||||
if (!providerState.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -122,10 +165,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||
}
|
||||
}
|
||||
|
||||
function isAdmin(user?: User): boolean {
|
||||
return !!user && user.Role === 1;
|
||||
}
|
||||
|
||||
export function useIsAdmin() {
|
||||
const { user } = useUser();
|
||||
return !!user && isAdmin(user);
|
||||
|
|
10
app/portainer/react/views/sidebar.ts
Normal file
10
app/portainer/react/views/sidebar.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { AngularSidebarService } from '@/react/sidebar/useSidebarState';
|
||||
import { Sidebar } from '@/react/sidebar/Sidebar';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
export const sidebarModule = angular
|
||||
.module('portainer.app.sidebar', [])
|
||||
.component('sidebar', r2a(Sidebar, []))
|
||||
.factory('SidebarService', AngularSidebarService).name;
|
|
@ -2,7 +2,7 @@ import { useQuery } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '../axios';
|
||||
|
||||
interface NodesCountResponse {
|
||||
export interface NodesCountResponse {
|
||||
nodes: number;
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,7 @@ export async function getNodesCount() {
|
|||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
Edition: string;
|
||||
Version: string;
|
||||
InstanceID: string;
|
||||
}
|
||||
|
@ -24,6 +25,10 @@ export async function getStatus() {
|
|||
try {
|
||||
const { data } = await axios.get<StatusResponse>(buildUrl());
|
||||
|
||||
if (process.env.PORTAINER_EDITION !== 'CE') {
|
||||
data.Edition = 'Business Edition';
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
|
@ -36,6 +41,22 @@ export function useStatus<T = StatusResponse>(
|
|||
return useQuery(['status'], () => getStatus(), { select });
|
||||
}
|
||||
|
||||
export interface VersionResponse {
|
||||
// Whether portainer has an update available
|
||||
UpdateAvailable: boolean;
|
||||
// The latest version available
|
||||
LatestVersion: string;
|
||||
}
|
||||
|
||||
export async function getVersionStatus() {
|
||||
try {
|
||||
const { data } = await axios.get<VersionResponse>(buildUrl('version'));
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(action?: string) {
|
||||
let url = '/status';
|
||||
|
||||
|
|
|
@ -126,12 +126,6 @@ angular.module('portainer.app').factory('LocalStorage', [
|
|||
const activeTab = localStorageService.get('active_tab_' + key);
|
||||
return activeTab === null ? 0 : activeTab;
|
||||
},
|
||||
storeToolbarToggle(value) {
|
||||
localStorageService.set('toolbar_toggle', value);
|
||||
},
|
||||
getToolbarToggle() {
|
||||
return localStorageService.get('toolbar_toggle');
|
||||
},
|
||||
storeLogoutReason: (reason) => localStorageService.set('logout_reason', reason),
|
||||
getLogoutReason: () => localStorageService.get('logout_reason'),
|
||||
cleanLogoutReason: () => localStorageService.remove('logout_reason'),
|
||||
|
|
|
@ -22,7 +22,6 @@ import {
|
|||
confirmContainerRecreation,
|
||||
confirmServiceForceUpdate,
|
||||
confirmStackUpdate,
|
||||
confirmKubeconfigSelection,
|
||||
selectRegistry,
|
||||
} from './prompt';
|
||||
|
||||
|
@ -58,7 +57,6 @@ export function ModalServiceAngular() {
|
|||
confirmStackUpdate,
|
||||
selectRegistry,
|
||||
confirmContainerDeletion,
|
||||
confirmKubeconfigSelection,
|
||||
confirmForceChangePassword,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -177,30 +177,6 @@ export function confirmStackUpdate(
|
|||
switchEle.prop('style', 'margin-left:20px');
|
||||
}
|
||||
|
||||
export function confirmKubeconfigSelection(
|
||||
options: InputOption[],
|
||||
expiryMessage: string,
|
||||
callback: PromptCallback
|
||||
) {
|
||||
const message = sanitize(
|
||||
`Select the kubernetes environment(s) to add to the kubeconfig file.</br>${expiryMessage}`
|
||||
);
|
||||
const box = prompt({
|
||||
title: 'Download kubeconfig file',
|
||||
inputType: 'checkbox',
|
||||
inputOptions: options,
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Download file',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
callback,
|
||||
});
|
||||
|
||||
customizeCheckboxPrompt(box, message, true, true);
|
||||
}
|
||||
|
||||
function customizeCheckboxPrompt(
|
||||
box: JQuery<HTMLElement>,
|
||||
message: string,
|
||||
|
|
|
@ -29,11 +29,6 @@ function StateManagerFactory(
|
|||
},
|
||||
};
|
||||
|
||||
manager.setVersionInfo = function (versionInfo) {
|
||||
state.application.versionStatus = versionInfo;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.dismissInformationPanel = function (id) {
|
||||
state.UI.dismissedInfoPanels[id] = true;
|
||||
LocalStorage.storeUIState(state.UI);
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
angular.module('portainer').service('TerminalWindow', function ($window) {
|
||||
const terminalHeight = 495;
|
||||
this.terminalopen = function () {
|
||||
const contentWrapperHeight = $window.innerHeight;
|
||||
const newContentWrapperHeight = contentWrapperHeight - terminalHeight;
|
||||
document.getElementById('content-wrapper').style.height = newContentWrapperHeight + 'px';
|
||||
document.getElementById('content-wrapper').style.overflowY = 'auto';
|
||||
document.getElementById('sidebar-wrapper').style.height = newContentWrapperHeight + 'px';
|
||||
};
|
||||
this.terminalclose = function () {
|
||||
const wrapperCSS = {
|
||||
height: '100%',
|
||||
overflowY: 'initial',
|
||||
};
|
||||
document.getElementById('content-wrapper').style.height = wrapperCSS.height;
|
||||
document.getElementById('content-wrapper').style.overflowY = wrapperCSS.overflowY;
|
||||
document.getElementById('sidebar-wrapper').style.height = wrapperCSS.height;
|
||||
};
|
||||
this.terminalresize = function () {
|
||||
const contentWrapperHeight = $window.innerHeight;
|
||||
const newContentWrapperHeight = contentWrapperHeight - terminalHeight;
|
||||
document.getElementById('content-wrapper').style.height = newContentWrapperHeight + 'px';
|
||||
document.getElementById('content-wrapper').style.overflowY = 'auto';
|
||||
document.getElementById('sidebar-wrapper').style.height = newContentWrapperHeight + 'px';
|
||||
};
|
||||
});
|
20
app/portainer/services/terminal-window.ts
Normal file
20
app/portainer/services/terminal-window.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
const terminalHeight = 495;
|
||||
|
||||
export function terminalClose() {
|
||||
update('100%', 'initial');
|
||||
}
|
||||
|
||||
export function terminalResize() {
|
||||
const contentWrapperHeight = window.innerHeight;
|
||||
const newContentWrapperHeight = contentWrapperHeight - terminalHeight;
|
||||
update(`${newContentWrapperHeight}px`, 'auto');
|
||||
}
|
||||
|
||||
function update(height: string, overflowY: string) {
|
||||
const pageWrapper = document.getElementById('pageWrapper-wrapper');
|
||||
|
||||
if (pageWrapper) {
|
||||
pageWrapper.style.height = height;
|
||||
pageWrapper.style.overflowY = overflowY;
|
||||
}
|
||||
}
|
|
@ -111,7 +111,7 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
|||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid || !dirty}
|
||||
dataCy="settings-edgeComputeButton"
|
||||
data-cy="settings-edgeComputeButton"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Saving settings..."
|
||||
>
|
||||
|
|
|
@ -145,7 +145,7 @@ export function SettingsFDO({ settings, onSubmit }: Props) {
|
|||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid || !dirty}
|
||||
dataCy="settings-fdoButton"
|
||||
data-cy="settings-fdoButton"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Saving settings..."
|
||||
>
|
||||
|
|
|
@ -248,7 +248,7 @@ export function SettingsOpenAMT({ settings, onSubmit }: Props) {
|
|||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid || !dirty}
|
||||
dataCy="settings-fdoButton"
|
||||
data-cy="settings-fdoButton"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Saving settings..."
|
||||
>
|
||||
|
|
|
@ -1,19 +1,26 @@
|
|||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import {
|
||||
mutationOptions,
|
||||
withError,
|
||||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
|
||||
import { PublicSettingsViewModel } from '../models/settings';
|
||||
|
||||
import {
|
||||
publicSettings,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
getPublicSettings,
|
||||
} from './settings.service';
|
||||
import { Settings } from './types';
|
||||
|
||||
export function usePublicSettings() {
|
||||
return useQuery(['settings', 'public'], () => publicSettings(), {
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to retrieve settings');
|
||||
},
|
||||
export function usePublicSettings<T = PublicSettingsViewModel>(
|
||||
select?: (settings: PublicSettingsViewModel) => T
|
||||
) {
|
||||
return useQuery(['settings', 'public'], () => getPublicSettings(), {
|
||||
select,
|
||||
...withError('Unable to retrieve public settings'),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -24,27 +31,18 @@ export function useSettings<T = Settings>(
|
|||
return useQuery(['settings'], getSettings, {
|
||||
select,
|
||||
enabled,
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Unable to retrieve settings',
|
||||
},
|
||||
},
|
||||
...withError('Unable to retrieve settings'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSettingsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(updateSettings, {
|
||||
onSuccess() {
|
||||
return queryClient.invalidateQueries(['settings']);
|
||||
},
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Unable to update settings',
|
||||
},
|
||||
},
|
||||
});
|
||||
return useMutation(
|
||||
updateSettings,
|
||||
mutationOptions(
|
||||
withInvalidate(queryClient, [['settings'], ['cloud']]),
|
||||
withError('Unable to update settings')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,11 +2,13 @@ import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
|||
|
||||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { Settings } from './types';
|
||||
import { PublicSettingsResponse, Settings } from './types';
|
||||
|
||||
export async function publicSettings() {
|
||||
export async function getPublicSettings() {
|
||||
try {
|
||||
const { data } = await axios.get(buildUrl('public'));
|
||||
const { data } = await axios.get<PublicSettingsResponse>(
|
||||
buildUrl('public')
|
||||
);
|
||||
return new PublicSettingsViewModel(data);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
|
|
|
@ -132,3 +132,24 @@ export interface Settings {
|
|||
AsyncMode: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
|
||||
LogoURL: string;
|
||||
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
||||
AuthenticationMethod: AuthenticationMethod;
|
||||
// Whether edge compute features are enabled
|
||||
EnableEdgeComputeFeatures: boolean;
|
||||
// Supported feature flags
|
||||
Features: Record<string, boolean>;
|
||||
// The URL used for oauth login
|
||||
OAuthLoginURI: string;
|
||||
// The URL used for oauth logout
|
||||
OAuthLogoutURI: string;
|
||||
// Whether portainer internal auth view will be hidden
|
||||
OAuthHideInternalAuth: boolean;
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry: boolean;
|
||||
// The expiry of a Kubeconfig
|
||||
KubeconfigExpiry: string;
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
|
|||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
dataCy="team-createTeamButton"
|
||||
data-cy="team-createTeamButton"
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Creating team..."
|
||||
>
|
||||
|
|
|
@ -1,16 +1,37 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { Role as TeamRole, TeamMembership } from '../teams/types';
|
||||
|
||||
import { User, UserId } from './types';
|
||||
import { isAdmin } from './user.helpers';
|
||||
import { getUserMemberships, getUsers } from './user.service';
|
||||
|
||||
export function useUserMembership(userId?: UserId) {
|
||||
interface UseUserMembershipOptions<TSelect> {
|
||||
select?(userMemberships: TeamMembership[]): TSelect;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useUserMembership<TSelect = TeamMembership[]>(
|
||||
userId: UserId,
|
||||
{ enabled, select }: UseUserMembershipOptions<TSelect> = {}
|
||||
) {
|
||||
return useQuery(
|
||||
['users', userId, 'memberships'],
|
||||
() => getUserMemberships(userId),
|
||||
{ enabled: !!userId }
|
||||
{ select, enabled }
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsTeamLeader(user: User) {
|
||||
const query = useUserMembership(user.Id, {
|
||||
enabled: !isAdmin(user),
|
||||
select: (memberships) =>
|
||||
memberships.some((membership) => membership.Role === TeamRole.TeamLeader),
|
||||
});
|
||||
|
||||
return isAdmin(user) ? true : query.data;
|
||||
}
|
||||
|
||||
export function useUsers<T = User[]>(
|
||||
includeAdministrator: boolean,
|
||||
enabled = true,
|
||||
|
|
|
@ -3,3 +3,7 @@ import { Role, User } from './types';
|
|||
export function filterNonAdministratorUsers(users: User[]) {
|
||||
return users.filter((user) => user.Role !== Role.Admin);
|
||||
}
|
||||
|
||||
export function isAdmin(user?: User): boolean {
|
||||
return !!user && user.Role === 1;
|
||||
}
|
||||
|
|
|
@ -25,12 +25,8 @@ export async function getUser(id: UserId) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getUserMemberships(id?: UserId) {
|
||||
export async function getUserMemberships(id: UserId) {
|
||||
try {
|
||||
if (!id) {
|
||||
throw new Error('missing id');
|
||||
}
|
||||
|
||||
const { data } = await axios.get<TeamMembership[]>(
|
||||
buildUrl(id, 'memberships')
|
||||
);
|
||||
|
|
|
@ -50,7 +50,6 @@ class AuthenticationController {
|
|||
};
|
||||
|
||||
this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this);
|
||||
this.checkForLatestVersionAsync = this.checkForLatestVersionAsync.bind(this);
|
||||
this.postLoginSteps = this.postLoginSteps.bind(this);
|
||||
|
||||
this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this);
|
||||
|
@ -137,23 +136,6 @@ class AuthenticationController {
|
|||
}
|
||||
}
|
||||
|
||||
async checkForLatestVersionAsync() {
|
||||
let versionInfo = {
|
||||
UpdateAvailable: false,
|
||||
LatestVersion: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const versionStatus = await this.StatusService.version();
|
||||
if (versionStatus.UpdateAvailable) {
|
||||
versionInfo.UpdateAvailable = true;
|
||||
versionInfo.LatestVersion = versionStatus.LatestVersion;
|
||||
}
|
||||
} finally {
|
||||
this.StateManager.setVersionInfo(versionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
async postLoginSteps() {
|
||||
await this.StateManager.initialize();
|
||||
|
||||
|
@ -161,7 +143,6 @@ class AuthenticationController {
|
|||
this.$analytics.setUserRole(isAdmin ? 'admin' : 'standard-user');
|
||||
|
||||
await this.checkForEndpointsAsync();
|
||||
await this.checkForLatestVersionAsync();
|
||||
}
|
||||
/**
|
||||
* END POST LOGIN STEPS SECTION
|
||||
|
|
|
@ -1,39 +1,14 @@
|
|||
angular.module('portainer.app').controller('MainController', [
|
||||
'$scope',
|
||||
'LocalStorage',
|
||||
'StateManager',
|
||||
'EndpointProvider',
|
||||
'ThemeManager',
|
||||
function ($scope, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
|
||||
/**
|
||||
* Sidebar Toggle & Cookie Control
|
||||
*/
|
||||
var mobileView = 992;
|
||||
$scope.getWidth = function () {
|
||||
return window.innerWidth;
|
||||
};
|
||||
angular.module('portainer.app').controller('MainController', MainController);
|
||||
|
||||
$scope.applicationState = StateManager.getState();
|
||||
$scope.endpointState = EndpointProvider.endpoint();
|
||||
/* @ngInject */
|
||||
function MainController($scope, StateManager, ThemeManager, SidebarService) {
|
||||
/**
|
||||
* Sidebar Toggle & Cookie Control
|
||||
*/
|
||||
|
||||
$scope.$watch($scope.getWidth, function (newValue) {
|
||||
if (newValue >= mobileView) {
|
||||
const toggleValue = LocalStorage.getToolbarToggle();
|
||||
$scope.toggle = typeof toggleValue === 'boolean' ? toggleValue : !window.ddExtension;
|
||||
} else {
|
||||
$scope.toggle = false;
|
||||
}
|
||||
});
|
||||
$scope.applicationState = StateManager.getState();
|
||||
|
||||
$scope.toggleSidebar = function () {
|
||||
$scope.toggle = !$scope.toggle;
|
||||
LocalStorage.storeToolbarToggle($scope.toggle);
|
||||
};
|
||||
$scope.isSidebarOpen = SidebarService.isSidebarOpen;
|
||||
|
||||
window.onresize = function () {
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
ThemeManager.autoTheme();
|
||||
},
|
||||
]);
|
||||
ThemeManager.autoTheme();
|
||||
}
|
||||
|
|
|
@ -1,131 +0,0 @@
|
|||
<!-- Sidebar -->
|
||||
<div id="sidebar-wrapper">
|
||||
<div class="sidebar-header">
|
||||
<a ui-sref="portainer.home" data-cy="portainerSidebar-homeImage">
|
||||
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo" />
|
||||
<img ng-if="!logo" src="~@/assets/images/logo_alt.svg" class="img-responsive logo" alt="Portainer" />
|
||||
</a>
|
||||
<a ng-click="toggleSidebar()"><span class="menu-icon glyphicon glyphicon-transfer"></span></a>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<ul class="sidebar">
|
||||
<sidebar-menu-item path="portainer.home" icon-class="fa-home fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-home" title="Home">Home</sidebar-menu-item>
|
||||
|
||||
<li class="sidebar-title endpoint-name" ng-if="applicationState.endpoint.name">
|
||||
<span class="fa fa-plug space-right"></span>{{ applicationState.endpoint.name }}
|
||||
<kubectl-shell ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider === 'KUBERNETES'" class="kubectl-shell"></kubectl-shell>
|
||||
</li>
|
||||
<div ng-if="applicationState.endpoint.mode">
|
||||
<kubernetes-sidebar
|
||||
ng-if="applicationState.endpoint.mode.provider === 'KUBERNETES'"
|
||||
is-sidebar-open="toggle"
|
||||
endpoint-id="endpointId"
|
||||
admin-access="isAdmin"
|
||||
></kubernetes-sidebar>
|
||||
|
||||
<azure-sidebar ng-if="applicationState.endpoint.mode.provider === 'AZURE'" environment-id="endpointId"> </azure-sidebar>
|
||||
|
||||
<docker-sidebar
|
||||
ng-if="applicationState.endpoint.mode.provider !== 'AZURE' && applicationState.endpoint.mode.provider !== 'KUBERNETES'"
|
||||
current-route-name="$state.current.name"
|
||||
is-sidebar-open="toggle"
|
||||
show-stacks="showStacks"
|
||||
endpoint-api-version="applicationState.endpoint.apiVersion"
|
||||
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
|
||||
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"
|
||||
admin-access="isAdmin || isEndpointAdmin"
|
||||
offline-mode="endpointState.OfflineMode"
|
||||
endpoint-id="endpointId"
|
||||
></docker-sidebar>
|
||||
</div>
|
||||
|
||||
<sidebar-section title="Edge compute" ng-if="isAdmin && applicationState.application.enableEdgeComputeFeatures">
|
||||
<sidebar-menu-item path="edge.devices" icon-class="fas fa-laptop-code fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeDevices" title="Edge Devices"
|
||||
>Edge Devices</sidebar-menu-item
|
||||
>
|
||||
<sidebar-menu-item path="edge.groups" icon-class="fa-object-group fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeGroups" title="Edge Groups"
|
||||
>Edge Groups</sidebar-menu-item
|
||||
>
|
||||
<sidebar-menu-item path="edge.stacks" icon-class="fa-layer-group fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeStacks" title="Edge Stacks"
|
||||
>Edge Stacks</sidebar-menu-item
|
||||
>
|
||||
<sidebar-menu-item path="edge.jobs" icon-class="fa-clock fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeJobs" title="Edge Jobs"
|
||||
>Edge Jobs</sidebar-menu-item
|
||||
>
|
||||
</sidebar-section>
|
||||
|
||||
<sidebar-section ng-if="showUsersSection" title="Settings">
|
||||
<sidebar-menu
|
||||
ng-show="display"
|
||||
icon-class="fa-users fa-fw"
|
||||
label="Users"
|
||||
path="portainer.users"
|
||||
is-sidebar-open="toggle"
|
||||
children-paths="['portainer.users.user' ,'portainer.teams' ,'portainer.teams.team' ,'portainer.roles' ,'portainer.roles.role' ,'portainer.roles.new']"
|
||||
>
|
||||
<sidebar-menu-item path="portainer.teams" class-name="sidebar-sublist" data-cy="portainerSidebar-teams" title="Teams">Teams</sidebar-menu-item>
|
||||
<sidebar-menu-item path="portainer.roles" class-name="sidebar-sublist" data-cy="portainerSidebar-roles" title="Roles">Roles</sidebar-menu-item>
|
||||
</sidebar-menu>
|
||||
|
||||
<div ng-if="isAdmin">
|
||||
<sidebar-menu
|
||||
icon-class="fa-plug fa-fw"
|
||||
label="Environments"
|
||||
path="portainer.endpoints"
|
||||
is-sidebar-open="toggle"
|
||||
children-paths="['portainer.endpoints.endpoint', 'portainer.wizard', 'portainer.endpoints.endpoint.access', 'portainer.groups', 'portainer.groups.group', 'portainer.groups.group.access', 'portainer.groups.new', 'portainer.tags']"
|
||||
>
|
||||
<sidebar-menu-item path="portainer.groups" class-name="sidebar-sublist" data-cy="portainerSidebar-endpointGroups" title="Groups">Groups</sidebar-menu-item>
|
||||
<sidebar-menu-item path="portainer.tags" class-name="sidebar-sublist" data-cy="portainerSidebar-endpointTags" title="Tags">Tags</sidebar-menu-item>
|
||||
</sidebar-menu>
|
||||
|
||||
<sidebar-menu-item path="portainer.registries" icon-class="fa-database fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-registries" title="Registries"
|
||||
>Registries</sidebar-menu-item
|
||||
>
|
||||
|
||||
<sidebar-menu label="Authentication logs" icon-class="fa-history fa-fw" path="portainer.authLogs" is-sidebar-open="toggle" children-paths="['portainer.activityLogs']">
|
||||
<sidebar-menu-item path="portainer.activityLogs" class-name="sidebar-sublist" data-cy="portainerSidebar-activityLogs" title="Activity Logs"
|
||||
>Activity Logs</sidebar-menu-item
|
||||
>
|
||||
</sidebar-menu>
|
||||
|
||||
<sidebar-menu
|
||||
label="Settings"
|
||||
icon-class="fa-cogs fa-fw"
|
||||
path="portainer.settings"
|
||||
is-sidebar-open="toggle"
|
||||
children-paths="['portainer.settings.authentication', 'portainer.settings.edgeCompute']"
|
||||
>
|
||||
<sidebar-menu-item
|
||||
path="portainer.settings.authentication"
|
||||
class-name="sidebar-sublist"
|
||||
data-cy="portainerSidebar-authentication"
|
||||
title="Authentication"
|
||||
ng-show="display"
|
||||
>Authentication</sidebar-menu-item
|
||||
>
|
||||
<sidebar-menu-item path="portainer.settings.edgeCompute" class-name="sidebar-sublist" data-cy="portainerSidebar-edge-compute" title="Edge Compute"
|
||||
>Edge Compute</sidebar-menu-item
|
||||
>
|
||||
|
||||
<div class="sidebar-sublist">
|
||||
<a href="https://www.portainer.io/community_help" target="_blank" data-cy="portainerSidebar-help" title="Help / About">Help / About</a>
|
||||
</div>
|
||||
</sidebar-menu>
|
||||
</div>
|
||||
</sidebar-section>
|
||||
</ul>
|
||||
<div class="sidebar-footer-content">
|
||||
<div class="update-notification" ng-if="applicationState.application.versionStatus.UpdateAvailable">
|
||||
<a target="_blank" href="https://github.com/portainer/portainer/releases/tag/{{ applicationState.application.versionStatus.LatestVersion }}" style="color: #091e5d">
|
||||
<i class="fa-lg fas fa-cloud-download-alt" style="margin-right: 2px"></i> A new version is available
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<img src="~@/assets/images/logo_small.png" class="img-responsive logo" alt="Portainer" />
|
||||
<span class="version" data-cy="portainerSidebar-versionNumber">{{ uiVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Sidebar -->
|
|
@ -1,70 +0,0 @@
|
|||
angular.module('portainer.app').controller('SidebarController', SidebarController);
|
||||
|
||||
function SidebarController($rootScope, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider, SettingsService) {
|
||||
$scope.applicationState = StateManager.getState();
|
||||
$scope.endpointState = EndpointProvider.endpoint();
|
||||
$scope.display = !window.ddExtension;
|
||||
|
||||
function checkPermissions(memberships) {
|
||||
var isLeader = false;
|
||||
angular.forEach(memberships, function (membership) {
|
||||
if (membership.Role === 1) {
|
||||
isLeader = true;
|
||||
}
|
||||
});
|
||||
$scope.isTeamLeader = isLeader;
|
||||
}
|
||||
|
||||
function isClusterAdmin() {
|
||||
return Authentication.isAdmin();
|
||||
}
|
||||
|
||||
async function initView() {
|
||||
$scope.uiVersion = StateManager.getState().application.version;
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
|
||||
$scope.endpointId = EndpointProvider.endpointID();
|
||||
$scope.showStacks = shouldShowStacks();
|
||||
|
||||
const userDetails = Authentication.getUserDetails();
|
||||
const isAdmin = isClusterAdmin();
|
||||
$scope.isAdmin = isAdmin;
|
||||
$scope.showUsersSection = isAdmin;
|
||||
|
||||
if (!isAdmin) {
|
||||
try {
|
||||
const memberships = await UserService.userMemberships(userDetails.ID);
|
||||
checkPermissions(memberships);
|
||||
const settings = await SettingsService.publicSettings();
|
||||
$scope.showUsersSection = $scope.isTeamLeader && !settings.TeamSync;
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve user memberships');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
|
||||
function shouldShowStacks() {
|
||||
if (isClusterAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const endpoint = EndpointProvider.currentEndpoint();
|
||||
if (!endpoint || !endpoint.SecuritySettings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return endpoint.SecuritySettings.allowStackManagementForRegularUsers;
|
||||
}
|
||||
|
||||
$transitions.onEnter({}, async () => {
|
||||
$scope.endpointId = EndpointProvider.endpointID();
|
||||
$scope.showStacks = shouldShowStacks();
|
||||
$scope.isAdmin = isClusterAdmin();
|
||||
|
||||
if ($scope.applicationState.endpoint.name) {
|
||||
document.title = `${$rootScope.defaultTitle} | ${$scope.applicationState.endpoint.name}`;
|
||||
}
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue