From a6666dbb8531abd33c3c68c15bc99644aa911077 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Mon, 1 May 2023 11:47:25 -0700 Subject: [PATCH] Redo web lock feature (#25305) Also reverts body `overflow: hidden` Fixes https://github.com/gravitational/teleport/issues/24885 --- lib/web/apiserver.go | 35 +- lib/web/ui/lock.go | 70 ++++ lib/web/ui/usercontext.go | 4 + lib/web/ui/usercontext_test.go | 1 + web/packages/design/src/Button/Button.jsx | 24 +- web/packages/teleport/index.html | 4 +- .../teleport/src/Locks/CreateLock.test.tsx | 138 ------- .../teleport/src/Locks/CreateLock.tsx | 192 --------- .../teleport/src/Locks/Locks.test.tsx | 72 ---- web/packages/teleport/src/Locks/Locks.tsx | 182 --------- .../teleport/src/Locks/NewLock.test.tsx | 147 ------- web/packages/teleport/src/Locks/NewLock.tsx | 270 ------------ web/packages/teleport/src/Locks/index.ts | 19 - web/packages/teleport/src/Locks/shared.tsx | 43 -- .../teleport/src/Locks/testFixtures.ts | 225 ---------- web/packages/teleport/src/Locks/types.ts | 92 ----- .../src/Locks/useGetTargetData.test.tsx | 160 -------- .../teleport/src/Locks/useGetTargetData.tsx | 137 ------- .../teleport/src/Locks/useLocks.test.tsx | 56 --- web/packages/teleport/src/Locks/useLocks.tsx | 52 --- .../src/LocksV2/Locks/DeleteLockDialogue.tsx | 71 ++++ .../teleport/src/LocksV2/Locks/Locks.tsx | 210 ++++++++++ .../teleport/src/LocksV2/Locks/index.ts | 18 + .../NewLock/LockCheckout/LockCheckout.tsx | 385 ++++++++++++++++++ .../src/LocksV2/NewLock/LockCheckout/index.ts | 18 + .../NewLock/LockCheckout/shield-check.png | Bin 0 -> 38458 bytes .../teleport/src/LocksV2/NewLock/NewLock.tsx | 291 +++++++++++++ .../ResourceList/HybridList/HybridList.tsx | 126 ++++++ .../LocksV2/NewLock/ResourceList/Logins.tsx | 96 +++++ .../ServerSideSupportedList/Desktops.tsx | 74 ++++ .../ServerSideSupportedList/Nodes.tsx | 87 ++++ .../ServerSideSupportedList.tsx | 211 ++++++++++ .../ResourceList/SimpleList/MfaDevices.tsx | 106 +++++ .../NewLock/ResourceList/SimpleList/Roles.tsx | 58 +++ .../ResourceList/SimpleList/SimpleList.tsx | 118 ++++++ .../NewLock/ResourceList/SimpleList/Users.tsx | 99 +++++ .../LocksV2/NewLock/ResourceList/common.tsx | 91 +++++ .../teleport/src/LocksV2/NewLock/common.tsx | 128 ++++++ .../teleport/src/LocksV2/NewLock/index.ts | 19 + web/packages/teleport/src/LocksV2/common.tsx | 29 ++ .../hooks/useServersidePagination.ts | 2 + web/packages/teleport/src/config.ts | 20 +- web/packages/teleport/src/features.tsx | 24 +- web/packages/teleport/src/mocks/contexts.ts | 1 + .../teleport/src/services/locks/index.ts | 18 + .../teleport/src/services/locks/locks.ts | 78 ++++ .../teleport/src/services/locks/types.ts | 48 +++ .../teleport/src/services/user/makeAcl.ts | 2 + .../teleport/src/services/user/types.ts | 1 + .../teleport/src/services/user/user.test.ts | 7 + .../teleport/src/stores/storeUserContext.ts | 4 + web/packages/teleport/src/teleportContext.tsx | 5 + web/packages/teleport/src/types.ts | 2 + 53 files changed, 2527 insertions(+), 1843 deletions(-) create mode 100644 lib/web/ui/lock.go delete mode 100644 web/packages/teleport/src/Locks/CreateLock.test.tsx delete mode 100644 web/packages/teleport/src/Locks/CreateLock.tsx delete mode 100644 web/packages/teleport/src/Locks/Locks.test.tsx delete mode 100644 web/packages/teleport/src/Locks/Locks.tsx delete mode 100644 web/packages/teleport/src/Locks/NewLock.test.tsx delete mode 100644 web/packages/teleport/src/Locks/NewLock.tsx delete mode 100644 web/packages/teleport/src/Locks/index.ts delete mode 100644 web/packages/teleport/src/Locks/shared.tsx delete mode 100644 web/packages/teleport/src/Locks/testFixtures.ts delete mode 100644 web/packages/teleport/src/Locks/types.ts delete mode 100644 web/packages/teleport/src/Locks/useGetTargetData.test.tsx delete mode 100644 web/packages/teleport/src/Locks/useGetTargetData.tsx delete mode 100644 web/packages/teleport/src/Locks/useLocks.test.tsx delete mode 100644 web/packages/teleport/src/Locks/useLocks.tsx create mode 100644 web/packages/teleport/src/LocksV2/Locks/DeleteLockDialogue.tsx create mode 100644 web/packages/teleport/src/LocksV2/Locks/Locks.tsx create mode 100644 web/packages/teleport/src/LocksV2/Locks/index.ts create mode 100644 web/packages/teleport/src/LocksV2/NewLock/LockCheckout/LockCheckout.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/LockCheckout/index.ts create mode 100644 web/packages/teleport/src/LocksV2/NewLock/LockCheckout/shield-check.png create mode 100644 web/packages/teleport/src/LocksV2/NewLock/NewLock.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/HybridList/HybridList.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/Logins.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Desktops.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Nodes.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/MfaDevices.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Users.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/ResourceList/common.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/common.tsx create mode 100644 web/packages/teleport/src/LocksV2/NewLock/index.ts create mode 100644 web/packages/teleport/src/LocksV2/common.tsx create mode 100644 web/packages/teleport/src/services/locks/index.ts create mode 100644 web/packages/teleport/src/services/locks/locks.ts create mode 100644 web/packages/teleport/src/services/locks/types.ts diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d5914fbe8bd92..6c22762375e7b 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -2404,15 +2404,6 @@ func createIdentityContext(login string, sessionCtx *SessionContext) (srv.Identi }, nil } -type UILock struct { - Name string `json:"name"` - Message string `json:"message"` - Expires string `json:"expires"` - CreatedAt string `json:"createdAt"` - CreatedBy string `json:"createdBy"` - Targets types.LockTarget `json:"targets"` -} - func (h *Handler) getClusterLocks( w http.ResponseWriter, r *http.Request, @@ -2429,30 +2420,8 @@ func (h *Handler) getClusterLocks( if err != nil { return nil, trace.Wrap(err) } - // Lock data structure is reformatted to save doing it on the client as the - // Table component doesn't support nested complex objects. And fails to properly - // sort or filter results. - lockList := make([]UILock, 0, len(locks)) - for _, lock := range locks { - var expires, createdAt string - if lock.LockExpiry() != nil { - expires = lock.LockExpiry().Format(time.RFC3339Nano) - } - if !lock.CreatedAt().IsZero() { - createdAt = lock.CreatedAt().Format(time.RFC3339Nano) - } - - lockList = append(lockList, UILock{ - Name: lock.GetMetadata().Name, - Message: lock.Message(), - Expires: expires, - Targets: lock.Target(), - CreatedAt: createdAt, - CreatedBy: lock.CreatedBy(), - }) - } - return lockList, nil + return ui.MakeLocks(locks), nil } type createLockReq struct { @@ -2505,7 +2474,7 @@ func (h *Handler) createClusterLock( return nil, trace.Wrap(err) } - return lock, nil + return ui.MakeLock(lock), nil } func (h *Handler) deleteClusterLock( diff --git a/lib/web/ui/lock.go b/lib/web/ui/lock.go new file mode 100644 index 0000000000000..ae0d677122206 --- /dev/null +++ b/lib/web/ui/lock.go @@ -0,0 +1,70 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ui + +import ( + "time" + + "github.com/gravitational/teleport/api/types" +) + +// Lock describes a lock suitable for webapp. +type Lock struct { + // Name is the name of this lock (uid). + Name string `json:"name"` + // Message is the message displayed to locked-out users. + Message string `json:"message"` + // Expires if set specifies when the lock ceases to be in force. + Expires string `json:"expires"` + // CreatedAt is the date time that the lock was created. + CreatedAt string `json:"createdAt"` + // CreatedBy is the username of the author of the lock. + CreatedBy string `json:"createdBy"` + // Target describes the set of interactions that the lock applies to. + Targets types.LockTarget `json:"targets"` +} + +// MakeLock creates a custom lock object suitable for the webapp. +func MakeLock(lock types.Lock) Lock { + var expiresAt, createdAt string + if lock.LockExpiry() != nil { + expiresAt = lock.LockExpiry().Format(time.RFC3339Nano) + } + if !lock.CreatedAt().IsZero() { + createdAt = lock.CreatedAt().Format(time.RFC3339Nano) + } + + return Lock{ + Name: lock.GetMetadata().Name, + Message: lock.Message(), + Expires: expiresAt, + Targets: lock.Target(), + CreatedAt: createdAt, + CreatedBy: lock.CreatedBy(), + } +} + +// MakeLocks makes lock objects suitable for the webapp. +func MakeLocks(locks []types.Lock) []Lock { + uiLocks := make([]Lock, 0, len(locks)) + + for _, lock := range locks { + uiLocks = append(uiLocks, MakeLock(lock)) + } + + return uiLocks +} diff --git a/lib/web/ui/usercontext.go b/lib/web/ui/usercontext.go index 5695fc05b9636..42aedc96f1b1d 100644 --- a/lib/web/ui/usercontext.go +++ b/lib/web/ui/usercontext.go @@ -100,6 +100,8 @@ type userACL struct { Integrations access `json:"integrations"` // DeviceTrust defines access to device trust. DeviceTrust access `json:"deviceTrust"` + // Locks defines access to locking resources. + Locks access `json:"lock"` } type authType string @@ -212,6 +214,7 @@ func NewUserContext(user types.User, userRoles services.RoleSet, features proto. license := newAccess(userRoles, ctx, types.KindLicense) deviceTrust := newAccess(userRoles, ctx, types.KindDevice) integrationsAccess := newAccess(userRoles, ctx, types.KindIntegration) + lockAccess := newAccess(userRoles, ctx, types.KindLock) acl := userACL{ AccessRequests: requestAccess, @@ -239,6 +242,7 @@ func NewUserContext(user types.User, userRoles services.RoleSet, features proto. Plugins: pluginsAccess, Integrations: integrationsAccess, DeviceTrust: deviceTrust, + Locks: lockAccess, } // local user diff --git a/lib/web/ui/usercontext_test.go b/lib/web/ui/usercontext_test.go index 3613dc5f55577..6374ee76286fd 100644 --- a/lib/web/ui/usercontext_test.go +++ b/lib/web/ui/usercontext_test.go @@ -88,6 +88,7 @@ func TestNewUserContext(t *testing.T) { require.Empty(t, cmp.Diff(userContext.ACL.AuthConnectors, allowedRW)) require.Empty(t, cmp.Diff(userContext.ACL.TrustedClusters, allowedRW)) require.Empty(t, cmp.Diff(userContext.ACL.AppServers, denied)) + require.Empty(t, cmp.Diff(userContext.ACL.Locks, denied)) require.Empty(t, cmp.Diff(userContext.ACL.DBServers, denied)) require.Empty(t, cmp.Diff(userContext.ACL.KubeServers, denied)) require.Empty(t, cmp.Diff(userContext.ACL.Events, denied)) diff --git a/web/packages/design/src/Button/Button.jsx b/web/packages/design/src/Button/Button.jsx index ff18e8b7c3707..7e4b503db4cbc 100644 --- a/web/packages/design/src/Button/Button.jsx +++ b/web/packages/design/src/Button/Button.jsx @@ -56,14 +56,26 @@ const themedStyles = props => { const { colors } = props.theme; const { kind } = props; - const style = { - '&:disabled': { - background: kind === 'text' ? 'none' : colors.buttons.bgDisabled, - color: colors.buttons.textDisabled, - cursor: 'auto', - }, + let disabledStyle = { + background: kind === 'text' ? 'none' : colors.buttons.bgDisabled, + color: colors.buttons.textDisabled, + cursor: 'auto', }; + let style = { + '&:disabled': disabledStyle, + }; + + // Using the pseudo class `:disabled` to style disabled state + // doesn't work for non form elements (e.g. anchor). So + // we target by attribute with square brackets. Only true + // when we change the underlying type for this component (button) + // using the `as` prop (eg: a, NavLink, Link). + if (props.as && props.disabled) { + disabledStyle.pointerEvents = 'none'; + style = { '&[disabled]': disabledStyle }; + } + return { ...kinds(props), ...style, diff --git a/web/packages/teleport/index.html b/web/packages/teleport/index.html index 5232e445bbccb..4e27d94abb435 100644 --- a/web/packages/teleport/index.html +++ b/web/packages/teleport/index.html @@ -8,10 +8,10 @@ - + - +
diff --git a/web/packages/teleport/src/Locks/CreateLock.test.tsx b/web/packages/teleport/src/Locks/CreateLock.test.tsx deleted file mode 100644 index 8744c1f5170ad..0000000000000 --- a/web/packages/teleport/src/Locks/CreateLock.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import ThemeProvider from 'design/ThemeProvider'; - -import { CreateLock } from './CreateLock'; -import { useLocks } from './useLocks'; - -import type { SelectedLockTarget } from './types'; - -jest.mock('teleport/useStickyClusterId', () => ({ - __esModule: true, - default: () => ({ clusterId: 'cluster-id' }), -})); - -const mockCreateLock = jest.fn(() => Promise.resolve()); - -jest.mock('./useLocks', () => ({ - useLocks: () => ({ createLock: mockCreateLock }), -})); - -describe('component: CreateLock', () => { - it('displays the list of targets to lock', () => { - const selectedLockTargets: SelectedLockTarget[] = [ - { type: 'user', name: 'worker' }, - { type: 'role', name: 'contractor' }, - ]; - render( - - {}} - selectedLockTargets={selectedLockTargets} - setSelectedLockTargets={() => {}} - /> - - ); - // One of the rows is a header. - expect(screen.getAllByRole('row')).toHaveLength(3); - }); - - it('can remove a target from the target list', async () => { - const selectedLockTargets: SelectedLockTarget[] = [ - { type: 'user', name: 'worker' }, - { type: 'role', name: 'contractor' }, - ]; - const cb = jest.fn(); - render( - - {}} - selectedLockTargets={selectedLockTargets} - setSelectedLockTargets={cb} - /> - - ); - await userEvent.click(screen.getAllByTestId('trash-btn')[1]); - expect(cb.mock.calls).toHaveLength(1); - // The `contractor` role has been removed. - expect(cb.mock.calls[0]).toEqual([[{ type: 'user', name: 'worker' }]]); - }); - - it('creates a lock with a message and ttl', async () => { - const selectedLockTargets: SelectedLockTarget[] = [ - { type: 'user', name: 'worker' }, - ]; - render( - - {}} - selectedLockTargets={selectedLockTargets} - setSelectedLockTargets={() => {}} - /> - - ); - const createLock = useLocks('cluster-id').createLock as jest.Mock; - let testClusterId, testLockData; - createLock.mockImplementation((clusterId, lockData) => { - testClusterId = clusterId; - testLockData = lockData; - return Promise.resolve(); - }); - await userEvent.type(screen.getByTestId('description'), 'you were bad'); - await userEvent.type(screen.getByTestId('ttl'), '5h'); - await userEvent.click(screen.getByRole('button', { name: 'Create locks' })); - expect(testClusterId).toBe('cluster-id'); - expect(testLockData).toStrictEqual({ - message: 'you were bad', - targets: { - user: 'worker', - }, - ttl: '5h', - }); - }); - - it('displays errors when create fails', async () => { - const selectedLockTargets: SelectedLockTarget[] = [ - { type: 'user', name: 'worker' }, - ]; - render( - - {}} - selectedLockTargets={selectedLockTargets} - setSelectedLockTargets={() => {}} - /> - - ); - const createLock = useLocks('cluster-id').createLock as jest.Mock; - // Reject the creation of the lock. - createLock.mockImplementation(() => - Promise.reject({ message: 'unable to create lock' }) - ); - await userEvent.click(screen.getByRole('button', { name: 'Create locks' })); - const alert = await screen.findByTestId('alert'); - expect(alert).toBeInTheDocument(); - }); -}); diff --git a/web/packages/teleport/src/Locks/CreateLock.tsx b/web/packages/teleport/src/Locks/CreateLock.tsx deleted file mode 100644 index 771fe8b388661..0000000000000 --- a/web/packages/teleport/src/Locks/CreateLock.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useRef, useState } from 'react'; -import styled from 'styled-components'; -import SlidePanel from 'design/SlidePanel'; - -import { ArrowBack, Trash } from 'design/Icon'; -import { Alert, Box, ButtonPrimary, Flex, Input, Text } from 'design'; -import Table, { Cell } from 'design/DataTable'; - -import useStickyClusterId from 'teleport/useStickyClusterId'; -import history from 'teleport/services/history'; -import cfg from 'teleport/config'; - -import { useLocks } from './useLocks'; -import { StyledSpinner } from './shared'; - -import type { Positions } from 'design/SlidePanel/SlidePanel'; -import type { CreateLockData, SelectedLockTarget } from './types'; -import type { ApiError } from 'teleport/services/api/parseError'; - -type Props = { - panelPosition: Positions; - setPanelPosition: (Positions) => void; - selectedLockTargets: SelectedLockTarget[]; - setSelectedLockTargets: (lockTargets: SelectedLockTarget[]) => void; -}; - -export function CreateLock({ - panelPosition, - setPanelPosition, - selectedLockTargets, - setSelectedLockTargets, -}: Props) { - const { clusterId } = useStickyClusterId(); - const { createLock } = useLocks(clusterId); - const [error, setError] = useState(''); - const [createPending, setCreatePending] = useState(false); - - const messageRef = useRef(null); - const ttlRef = useRef(null); - - function handleCreateLock() { - setError(''); - setCreatePending(true); - selectedLockTargets.forEach(async lockTarget => { - const lockData: CreateLockData = { - targets: { [lockTarget.type]: lockTarget.name }, - }; - const message = messageRef?.current?.value; - const ttl = ttlRef?.current?.value; - if (message) lockData.message = message; - if (ttl) lockData.ttl = ttl; - await createLock(clusterId, lockData) - .then(() => { - setTimeout(() => { - // It takes longer for the cache to be updated when adding locks so - // this waits 1s before redirecting to fetch the list again. - setCreatePending(false); - history.push(cfg.getLocksRoute(clusterId)); - }, 1000); - }) - .catch((e: ApiError) => { - setCreatePending(false); - setError(e.message); - }); - }); - } - - function onRemove(name) { - const index = selectedLockTargets.findIndex(target => target.name === name); - selectedLockTargets.splice(index, 1); - setSelectedLockTargets([...selectedLockTargets]); - } - - return ( - setPanelPosition('closed')} - > -
- {error && } - - setPanelPosition('closed')} - style={{ cursor: 'pointer' }} - /> - - - Create New Lock - - - - - ( - - - theme.colors.buttons.trashButton.default}; - border-radius: 2px; - :hover { - background-color: ${({ theme }) => - theme.colors.buttons.trashButton.hover}; - } - `} - data-testid="trash-btn" - /> - - ), - }, - ]} - emptyText="No Targets Found" - /> - - Message: - - - - TTL: - - -
- - - {createPending ? : <>Create locks} - - -
- ); -} - -const StyledTable = styled(Table)` - & > tbody > tr > td { - vertical-align: middle; - padding: 8px; - } - & > thead > tr > th { - background: ${props => props.theme.colors.spotBackground[1]}; - } - box-shadow: ${props => props.theme.boxShadow[0]}; - border-radius: 8px; - overflow: hidden; -` as typeof Table; diff --git a/web/packages/teleport/src/Locks/Locks.test.tsx b/web/packages/teleport/src/Locks/Locks.test.tsx deleted file mode 100644 index 8d444efcf32db..0000000000000 --- a/web/packages/teleport/src/Locks/Locks.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import ThemeProvider from 'design/ThemeProvider'; -import { MemoryRouter } from 'react-router'; - -import api from 'teleport/services/api'; - -import { Locks } from './Locks'; -import { HOOK_LIST as mockHookList } from './testFixtures'; - -jest.mock('teleport/useStickyClusterId', () => ({ - __esModule: true, - default: () => ({ clusterId: 'cluster-id' }), -})); - -jest.mock('teleport/services/api', () => ({ - get: () => new Promise(resolve => resolve(mockHookList)), - delete: jest.fn(() => new Promise(() => Promise.resolve())), -})); - -describe('component: Locks', () => { - it('displays the fetched locks', async () => { - render( - - - - - - ); - await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(5)); - }); - - it('can call to remove a lock', async () => { - render( - - - - - - ); - const deleteLock = api.delete as jest.Mock; - const mockDeleteLock = jest.fn(() => Promise.resolve()); - deleteLock.mockImplementation(mockDeleteLock); - await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(5)); - await userEvent.click(screen.getAllByTestId('trash-btn')[1]); - await waitFor(() => expect(mockDeleteLock.mock.calls).toHaveLength(1)); - - expect(mockDeleteLock.mock.calls).toEqual([ - [ - '/v1/webapi/sites/cluster-id/locks/60626e99-e91b-41b2-89fe-bf5d16b0c622', - ], - ]); - }); -}); diff --git a/web/packages/teleport/src/Locks/Locks.tsx b/web/packages/teleport/src/Locks/Locks.tsx deleted file mode 100644 index 42c956f16c649..0000000000000 --- a/web/packages/teleport/src/Locks/Locks.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { formatRelative } from 'date-fns'; - -import Table, { Cell, ClickableLabelCell } from 'design/DataTable'; -import { ButtonPrimary } from 'design/Button'; -import { Trash } from 'design/Icon'; - -import api from 'teleport/services/api'; -import cfg from 'teleport/config'; -import useStickyClusterId from 'teleport/useStickyClusterId'; -import { - FeatureBox, - FeatureHeader, - FeatureHeaderTitle, -} from 'teleport/components/Layout'; -import { NavLink } from 'teleport/components/Router'; - -import { useLocks } from './useLocks'; -import { StyledSpinner } from './shared'; -import { LockForTable } from './types'; - -function getFormattedDate(d: string): string { - try { - return formatRelative(new Date(d), Date.now()); - } catch (e) { - return ''; - } -} - -function lockTargetsMatcher( - targetValue: any, - searchValue: string, - propName: keyof LockForTable & string -) { - if (propName === 'targets') { - return targetValue.some( - ({ name, value }) => - name.toLocaleUpperCase().includes(searchValue) || - value.toLocaleUpperCase().includes(searchValue) || - `${name}: ${value}`.toLocaleUpperCase().includes(searchValue) - ); - } -} - -export function Locks() { - const { clusterId } = useStickyClusterId(); - const { locks, fetchLocks } = useLocks(clusterId); - const [deletePending, setDeletePending] = useState(''); - - function onDelete(lockName: string) { - if (deletePending) return; - setDeletePending(lockName); - api.delete(cfg.getLocksUrlWithUuid(clusterId, lockName)).then(() => { - // It takes longer for the cache to be updated when removing locks so - // this waits 1s before fetching the list again. - setTimeout(() => { - fetchLocks(clusterId); - setDeletePending(''); - }, 1000); - }); - } - - return ( - - - Session & Identity Locks - - + Add New Lock - - - ( - {}} /> - ), - }, - { - key: 'createdBy', - headerText: 'Locked By', - isSortable: true, - }, - { - key: 'createdAt', - headerText: 'Start Date', - isSortable: true, - render: ({ createdAt }) => ( - {getFormattedDate(createdAt)} - ), - }, - { - key: 'expires', - headerText: 'Expiration', - isSortable: true, - render: ({ expires }) => ( - {getFormattedDate(expires) || 'Never'} - ), - }, - { - key: 'message', - headerText: 'Message', - isSortable: true, - render: ({ message }) => {message}, - }, - { - altKey: 'options-btn', - render: ({ name }) => ( - - ), - }, - ]} - emptyText="No Locks Found" - isSearchable - customSearchMatchers={[lockTargetsMatcher]} - pagination={{ pageSize: 20 }} - /> - - ); -} - -type DeleteButtonProps = { - onDelete: () => void; - pending: boolean; -}; - -const DeleteButton = ({ onDelete, pending }: DeleteButtonProps) => { - return ( - - - {pending ? ( - - ) : ( - - )} - - - ); -}; - -const ButtonBG = styled.span` - cursor: pointer; - font-size: 13px; - border-radius: 2px; - padding: 8px; - background-color: ${({ theme }) => theme.colors.buttons.trashButton.default}; - :hover { - background-color: ${({ theme }) => theme.colors.buttons.trashButton.hover}; - } -`; diff --git a/web/packages/teleport/src/Locks/NewLock.test.tsx b/web/packages/teleport/src/Locks/NewLock.test.tsx deleted file mode 100644 index 8faac1e7ba9f6..0000000000000 --- a/web/packages/teleport/src/Locks/NewLock.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import ThemeProvider from 'design/ThemeProvider'; -import { MemoryRouter } from 'react-router'; - -import { useGetTargetData } from './useGetTargetData'; - -import NewLock from './NewLock'; -import { CreateLock } from './CreateLock'; -import { USER_RESULT } from './testFixtures'; - -jest.mock('teleport/useStickyClusterId', () => ({ - __esModule: true, - default: () => ({ clusterId: 'cluster-id' }), -})); - -jest.mock('./CreateLock', () => ({ - CreateLock: jest.fn().mockReturnValue(
), -})); - -jest.mock('./useGetTargetData', () => ({ - useGetTargetData: jest.fn(), -})); - -describe('component: NewLock', () => { - it('defaults to displaying the user targets', () => { - const ugt = useGetTargetData as jest.Mock; - ugt.mockImplementation(() => USER_RESULT); - render( - - - - - - ); - // eslint-disable-next-line testing-library/no-node-access - expect(document.querySelectorAll('tbody tr')).toHaveLength(3); - }); - - // These tests can be completed once react-select has been updated - // to enable testing with our current testing strategy. - it.todo('allows you to switch to other targets'); - it.todo('supports displaying additional targets'); - describe('displays target type', () => { - it.todo('role'); - it.todo('login'); - it.todo('node'); - it.todo('mfa device'); - it.todo('windows desktop'); - }); - it.todo('does not show a table for "logins"'); - it.todo('allows the collection of multiple target types'); - - it('disables submit if there are no targets added', () => { - const ugt = useGetTargetData as jest.Mock; - ugt.mockImplementation(() => USER_RESULT); - render( - - - - - - ); - expect(screen.getByText('Lock targets added (0)')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Proceed to lock' }) - ).toBeDisabled(); - }); - - it('passes selected targets to the CreateLock component', async () => { - const ugt = useGetTargetData as jest.Mock; - const cl = CreateLock as jest.Mock; - ugt.mockImplementation(() => USER_RESULT); - render( - - - - - - ); - expect(screen.getByText('Lock targets added (0)')).toBeInTheDocument(); - await userEvent.click(screen.getAllByRole('button', { name: '+ Add' })[1]); - await screen.findByText('Lock targets added (1)'); - await userEvent.click( - screen.getByRole('button', { name: 'Proceed to lock' }) - ); - expect( - cl.mock.calls[cl.mock.calls.length - 1][0].selectedLockTargets - ).toStrictEqual([ - { - type: 'user', - name: 'admin', - }, - ]); - }); - - it('allows freeform target id inputs', async () => { - const ugt = useGetTargetData as jest.Mock; - const cl = CreateLock as jest.Mock; - ugt.mockImplementation(() => USER_RESULT); - render( - - - - - - ); - expect(screen.getByText('Lock targets added (0)')).toBeInTheDocument(); - await userEvent.type( - screen.getByPlaceholderText('Quick add user'), - 'I am a uuid' - ); - await userEvent.click(screen.getAllByRole('button', { name: '+ Add' })[0]); - await screen.findByText('Lock targets added (1)'); - await userEvent.click( - screen.getByRole('button', { name: 'Proceed to lock' }) - ); - expect( - cl.mock.calls[cl.mock.calls.length - 1][0].selectedLockTargets - ).toStrictEqual([ - { - type: 'user', - name: 'I am a uuid', - }, - ]); - }); - - it.todo('allows freeform target id inputs depending on selected target type'); -}); diff --git a/web/packages/teleport/src/Locks/NewLock.tsx b/web/packages/teleport/src/Locks/NewLock.tsx deleted file mode 100644 index f749f3c93c9d8..0000000000000 --- a/web/packages/teleport/src/Locks/NewLock.tsx +++ /dev/null @@ -1,270 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useState } from 'react'; - -import { Box, ButtonPrimary, ButtonSecondary, Flex, Input, Text } from 'design'; -import { Cell, LabelCell } from 'design/DataTable'; -import Select from 'shared/components/Select'; -import { ArrowBack } from 'design/Icon'; - -import useStickyClusterId from 'teleport/useStickyClusterId'; -import history from 'teleport/services/history'; -import { - FeatureBox, - FeatureHeader, - FeatureHeaderTitle, -} from 'teleport/components/Layout'; -import cfg from 'teleport/config'; - -import { CreateLock } from './CreateLock'; -import { StyledTable } from './shared'; - -import { lockTargets, useGetTargetData } from './useGetTargetData'; - -import type { AdditionalTargets } from './useGetTargetData'; -import type { - LockTarget, - OnAdd, - SelectedLockTarget, - TargetListProps, -} from './types'; -import type { TableColumn } from 'design/DataTable/types'; -import type { Positions } from 'design/SlidePanel/SlidePanel'; - -// This is split out like this to allow the router to call 'NewLock' -// but also allow E to use 'NewLockContent' separately. -export default function NewLock() { - return ; -} - -export function NewLockContent({ - additionalTargets, -}: { - additionalTargets?: AdditionalTargets; -}) { - const { clusterId } = useStickyClusterId(); - const [createPanelPosition, setCreatePanelPosition] = - useState('closed'); - const [selectedTargetType, setSelectedTargetType] = useState({ - label: 'User', - value: 'user', - }); - const [selectedLockTargets, setSelectedLockTargets] = useState< - SelectedLockTarget[] - >([]); - const targetData = useGetTargetData( - selectedTargetType?.value, - clusterId, - additionalTargets - ); - - function onAdd(target: string) { - selectedLockTargets.push({ - type: selectedTargetType.value, - name: target, - }); - setSelectedLockTargets([...selectedLockTargets]); - } - - function onClear() { - setSelectedLockTargets([]); - } - - const disabledSubmit = !selectedLockTargets.length; - - return ( - - - - - - history.push(cfg.getLocksRoute(clusterId))} - style={{ cursor: 'pointer' }} - /> - Create New Lock - - - - - - setInputValue(e.currentTarget.value)} - /> - { - onAdd(inputValue); - setInputValue(''); - }} - disabled={ - !inputValue.length || - selectedLockTargets?.some( - target => - target.type === selectedTarget && target.name === inputValue - ) - } - > - + Add - - - ); -} diff --git a/web/packages/teleport/src/Locks/index.ts b/web/packages/teleport/src/Locks/index.ts deleted file mode 100644 index f30050a04dd56..0000000000000 --- a/web/packages/teleport/src/Locks/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Locks } from './Locks'; - -export default Locks; diff --git a/web/packages/teleport/src/Locks/shared.tsx b/web/packages/teleport/src/Locks/shared.tsx deleted file mode 100644 index e7334f9dc88d2..0000000000000 --- a/web/packages/teleport/src/Locks/shared.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import styled from 'styled-components'; - -import Table from 'design/DataTable'; -import { Spinner } from 'design/Icon'; - -export const StyledTable = styled(Table)` - & > tbody > tr > td { - vertical-align: middle; - padding: 8px; - } - border-radius: 8px; - overflow: hidden; -` as typeof Table; - -export const StyledSpinner = styled(Spinner)` - cursor: default; - padding: 8px 0; - animation: anim-rotate 2s infinite linear; - @keyframes anim-rotate { - 0% { - transform: rotate(0); - } - 100% { - transform: rotate(360deg); - } - } -`; diff --git a/web/packages/teleport/src/Locks/testFixtures.ts b/web/packages/teleport/src/Locks/testFixtures.ts deleted file mode 100644 index e4fc5dde9aec6..0000000000000 --- a/web/packages/teleport/src/Locks/testFixtures.ts +++ /dev/null @@ -1,225 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export const HOOK_LIST = [ - { - name: '1ecfe67f-a59b-4309-b6fc-a9981891e82a', - message: "you've been bad", - expires: '2023-03-18T02:14:01.659948Z', - createdAt: '', - createdBy: '', - targets: { user: 'worker' }, - }, - { - name: '3ec76143-1ebb-4328-acbb-83799919e2a8', - message: 'Forever gone', - expires: '', - createdAt: '2023-03-20T16:57:17.117411Z', - createdBy: 'tele-admin-local', - targets: { user: 'worker' }, - }, - { - name: '5df33ee0-6368-4f9d-b8d1-9a4121830018', - message: '', - expires: '2023-03-20T21:10:17.529834Z', - createdAt: '2023-03-20T19:10:17.533992Z', - createdBy: 'tele-admin-local', - targets: { user: 'worker' }, - }, - { - name: '60626e99-e91b-41b2-89fe-bf5d16b0c622', - message: 'No contractors allowed right now', - expires: '2023-03-20T19:36:15.028132Z', - createdAt: '2023-03-20T14:36:15.046728Z', - createdBy: 'tele-admin', - targets: { role: 'contractor' }, - }, -]; - -export const HOOK_CREATED = { - kind: 'lock', - version: 'v2', - metadata: { name: '1b807c9f-2144-4f7f-8d3e-9c1e14cb5b98' }, - spec: { - target: { user: 'banned' }, - message: "you've been bad", - expires: '2023-03-20T21:51:18.466627Z', - created_at: '0001-01-01T00:00:00Z', - }, -}; - -// Responses from the service fetch methods -// ex) desktopService.fetchDesktops, nodeService.fetchNodes, etc. - -export const MFA_DEVICES = [ - { - id: '4bac1adb-fdaa-4c31-a989-317892a9d1bd', - name: 'yubikey', - description: 'Hardware Key', - registeredDate: '2023-03-14T19:22:59.437Z', - lastUsedDate: '2023-03-21T19:03:54.874Z', - }, -]; - -export const DESKTOPS = { - agents: [ - { - os: 'windows', - name: 'watermelon', - addr: 'localhost.watermelon', - labels: [ - { - name: 'env', - value: 'test', - }, - { - name: 'os', - value: 'os', - }, - { - name: 'unique-id', - value: '47c38f49-b690-43fd-ac28-946e7a0a6188', - }, - { - name: 'windows-desktops', - value: 'watermelon', - }, - ], - host_id: '47c38f49-b690-43fd-ac28-946e7a0a6188', - logins: [], - }, - { - os: 'windows', - name: 'banana', - addr: 'localhost.banana', - labels: [ - { - name: 'env', - value: 'test', - }, - { - name: 'os', - value: 'linux', - }, - { - name: 'unique-id', - value: '4c3bd959-8444-492a-a383-a29378da93c9', - }, - { - name: 'windows-desktops', - value: 'banana', - }, - ], - host_id: '4c3bd959-8444-492a-a383-a29378da93c9', - logins: [], - }, - ], - startKey: '', - totalCount: 0, -}; - -export const NODES = { - agents: [ - { - id: 'e14baac6-15c1-42c2-a7d9-99410d21cf4c', - clusterId: 'local-test2', - hostname: 'node1.go.citadel', - labels: ['special:apple', 'user:orange'], - addr: '127.0.0.1:4022', - tunnel: false, - sshLogins: [], - }, - ], - startKey: '', - totalCount: 0, -}; - -export const ROLES = [ - { - id: 'role:admin', - kind: 'role', - name: 'admin', - content: '', - }, - { - id: 'role:contractor', - kind: 'role', - name: 'contractor', - content: '', - }, - { - id: 'role:locksmith', - kind: 'role', - name: 'locksmith', - content: '', - }, -]; - -export const USERS = [ - { - name: 'admin-local', - roles: ['access', 'admin', 'auditor', 'editor'], - authType: 'local', - }, - { - name: 'admin', - roles: ['access', 'admin', 'auditor', 'editor', 'locksmith'], - authType: 'local', - }, - { - name: 'worker', - roles: ['access', 'contractor'], - authType: 'local', - }, -]; - -export const mockedUseTeleportUtils = { - mfaService: { - fetchDevices: () => new Promise(resolve => resolve(MFA_DEVICES)), - }, - desktopService: { - fetchDesktops: () => new Promise(resolve => resolve(DESKTOPS)), - }, - nodeService: { - fetchNodes: () => new Promise(resolve => resolve(NODES)), - }, - resourceService: { - fetchRoles: () => new Promise(resolve => resolve(ROLES)), - }, - userService: { - fetchUsers: () => new Promise(resolve => resolve(USERS)), - }, -}; - -export const USER_RESULT = [ - { - name: 'admin-local', - targetValue: 'admin-local', - roles: 'access, admin, auditor, editor', - }, - { - name: 'admin', - targetValue: 'admin', - roles: 'access, admin, auditor, editor, locksmith', - }, - { name: 'worker', targetValue: 'worker', roles: 'access, contractor' }, -]; - -export const ROLES_RESULT = [ - { name: 'admin', targetValue: 'admin' }, - { name: 'contractor', targetValue: 'contractor' }, - { name: 'locksmith', targetValue: 'locksmith' }, -]; diff --git a/web/packages/teleport/src/Locks/types.ts b/web/packages/teleport/src/Locks/types.ts deleted file mode 100644 index cf1da58462a3d..0000000000000 --- a/web/packages/teleport/src/Locks/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { LabelDescription } from 'design/DataTable/types'; - -import { AgentLabel } from 'teleport/services/agents'; - -export type Lock = { - name: string; - message: string; - expires: string; - createdAt: string; - createdBy: string; - targets: { - user?: string; - role?: string; - login?: string; - node?: string; - mfa_device?: string; - windows_desktop?: string; - access_request?: string; - device?: string; - }; -}; - -export type LockForTable = { - name: string; - message: string; - expires: string; - createdAt: string; - createdBy: string; - targets: LabelDescription[]; -}; - -export type AllowedTargets = - | 'user' - | 'role' - | 'login' - | 'node' - | 'mfa_device' - | 'windows_desktop' - | 'access_request' - | 'device'; - -export type TableData = { - // targetValue is not displayed in the table, but is the value - // that will be used when creating the lock target - targetValue: string; - - labels?: AgentLabel[]; - - // these values are shown in the UI (each key is a separate column) - [key: string]: any; -}; - -export type LockTarget = { - label: string; - value: AllowedTargets; -}; - -export type SelectedLockTarget = { - type: AllowedTargets; - name: string; -}; - -export type OnAdd = (name: string) => void; - -export type TargetListProps = { - data: TableData[]; - onAdd: OnAdd; - selectedTarget: AllowedTargets; - selectedLockTargets: SelectedLockTarget[]; -}; - -export type CreateLockData = { - targets: { [K in AllowedTargets]?: string }; - message?: string; - ttl?: string; -}; diff --git a/web/packages/teleport/src/Locks/useGetTargetData.test.tsx b/web/packages/teleport/src/Locks/useGetTargetData.test.tsx deleted file mode 100644 index 05c9d74b41376..0000000000000 --- a/web/packages/teleport/src/Locks/useGetTargetData.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { renderHook } from '@testing-library/react-hooks'; - -import { useGetTargetData } from './useGetTargetData'; - -import { - mockedUseTeleportUtils, - ROLES_RESULT, - USER_RESULT, -} from './testFixtures'; - -jest.mock('teleport/useTeleport', () => ({ - __esModule: true, - default: () => mockedUseTeleportUtils, -})); - -describe('hook: useLocks', () => { - describe('can fetch and filter', () => { - it('mfa data', async () => { - async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetTargetData('windows_desktop', 'cluster-id') - ); - await waitForNextUpdate(); - expect(result.current).toStrictEqual([ - { - name: 'yubikey', - id: '4bac1adb-fdaa-4c31-a989-317892a9d1bd', - description: 'Hardware Key', - lastUsed: 'Tue, 21 Mar 2023 19:03:54 GMT', - }, - ]); - }; - }); - - it('desktops data', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetTargetData('windows_desktop', 'cluster-id') - ); - await waitForNextUpdate(); - expect(result.current).toStrictEqual([ - { - name: 'watermelon', - targetValue: 'watermelon', - addr: 'localhost.watermelon', - labels: [ - { - name: 'env', - value: 'test', - }, - { - name: 'os', - value: 'os', - }, - { - name: 'unique-id', - value: '47c38f49-b690-43fd-ac28-946e7a0a6188', - }, - { - name: 'windows-desktops', - value: 'watermelon', - }, - ], - }, - { - name: 'banana', - targetValue: 'banana', - addr: 'localhost.banana', - labels: [ - { - name: 'env', - value: 'test', - }, - { - name: 'os', - value: 'linux', - }, - { - name: 'unique-id', - value: '4c3bd959-8444-492a-a383-a29378da93c9', - }, - { - name: 'windows-desktops', - value: 'banana', - }, - ], - }, - ]); - }); - - it('nodes data', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetTargetData('node', 'cluster-id') - ); - await waitForNextUpdate(); - expect(result.current).toStrictEqual([ - { - name: 'node1.go.citadel', - targetValue: 'e14baac6-15c1-42c2-a7d9-99410d21cf4c', - addr: '127.0.0.1:4022', - labels: ['special:apple', 'user:orange'], - }, - ]); - }); - - it('roles data', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetTargetData('role', 'cluster-id') - ); - await waitForNextUpdate(); - expect(result.current).toStrictEqual(ROLES_RESULT); - }); - - it('user data', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetTargetData('user', 'cluster-id') - ); - await waitForNextUpdate(); - expect(result.current).toStrictEqual(USER_RESULT); - }); - - it('additionally supplied targets', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetTargetData('access_request', 'cluster-id', additionalTargets) - ); - await waitForNextUpdate(); - expect(result.current).toStrictEqual([accessRequestData]); - }); - }); -}); - -const accessRequestData = { - id: '942a14e8-6a16-40bb-a873-725cec0a3cca', - user: 'jane', - roles: 'access, editor', - created: new Date().toDateString(), - reason: 'testing', - targetValue: '942a14e8-6a16-40bb-a873-725cec0a3cca', -}; - -const additionalTargets = { - access_request: { - fetchData: async () => [accessRequestData], - }, -}; diff --git a/web/packages/teleport/src/Locks/useGetTargetData.tsx b/web/packages/teleport/src/Locks/useGetTargetData.tsx deleted file mode 100644 index cee189a4920e3..0000000000000 --- a/web/packages/teleport/src/Locks/useGetTargetData.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { useEffect, useMemo, useState } from 'react'; - -import useTeleport from 'teleport/useTeleport'; - -import type { AllowedTargets, LockTarget, TableData } from './types'; - -export const lockTargets: LockTarget[] = [ - { label: 'User', value: 'user' }, - { label: 'Role', value: 'role' }, - { label: 'Login', value: 'login' }, - { label: 'Node', value: 'node' }, - { label: 'MFA Device', value: 'mfa_device' }, - { label: 'Windows Desktop', value: 'windows_desktop' }, - // Skipped for now because it's not fully implemented. - // { label: 'Device', value: 'device' }, - // TODO(sshahcodes): add support for locking devices -]; - -export type UseGetTargetData = ( - targetType: AllowedTargets, - clusterId: string, - additionalTargets?: AdditionalTargets -) => TableData[]; - -export type AdditionalTargets = Partial< - Record }> ->; - -export const useGetTargetData: UseGetTargetData = ( - targetType, - clusterId, - additionalTargets -) => { - const [targetData, setTargetData] = useState(); - const { - desktopService: { fetchDesktops }, - mfaService: { fetchDevices }, - nodeService: { fetchNodes }, - resourceService: { fetchRoles }, - userService: { fetchUsers }, - } = useTeleport(); - - const targetDataFilters = useMemo< - Partial }>> - >(() => { - return { - user: { - fetchData: async () => { - const users = await fetchUsers(); - return users.map(u => ({ - name: u.name, - roles: u.roles.join(', '), - targetValue: u.name, - })); - }, - }, - role: { - fetchData: async () => { - const roles = await fetchRoles(); - return roles.map(r => ({ name: r.name, targetValue: r.name })); - }, - }, - node: { - fetchData: async () => { - const nodes = await fetchNodes(clusterId, { limit: 10 }); - return nodes.agents.map(n => ({ - name: n.hostname, - addr: n.addr, - labels: n.labels, - targetValue: n.id, - })); - }, - }, - mfa_device: { - fetchData: async () => { - const mfas = await fetchDevices(); - return mfas.map(m => ({ - name: m.name, - id: m.id, - description: m.description, - lastUsed: m.lastUsedDate.toUTCString(), - targetValue: m.id, - })); - }, - }, - windows_desktop: { - fetchData: async () => { - const desktops = await fetchDesktops(clusterId, { limit: 10 }); - return desktops.agents.map(d => ({ - name: d.name, - addr: d.addr, - labels: d.labels, - targetValue: d.name, - })); - }, - }, - }; - }, [ - clusterId, - fetchDesktops, - fetchDevices, - fetchNodes, - fetchRoles, - fetchUsers, - ]); - - useEffect(() => { - let action = - targetDataFilters[targetType] || additionalTargets?.[targetType]; - if (!action) { - // eslint-disable-next-line no-console - console.warn(`unknown target type ${targetType}`); - setTargetData([]); - return; - } - - action.fetchData().then(setTargetData); - }, [additionalTargets, targetDataFilters, targetType]); - - return targetData; -}; diff --git a/web/packages/teleport/src/Locks/useLocks.test.tsx b/web/packages/teleport/src/Locks/useLocks.test.tsx deleted file mode 100644 index bdd8d329e5e22..0000000000000 --- a/web/packages/teleport/src/Locks/useLocks.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { renderHook } from '@testing-library/react-hooks'; - -import { useLocks } from './useLocks'; - -import { - HOOK_LIST as mockHookList, - HOOK_CREATED as mockHookCreated, -} from './testFixtures'; - -jest.mock('teleport/services/api', () => ({ - get: () => new Promise(resolve => resolve(mockHookList)), - put: () => new Promise(resolve => resolve(mockHookCreated)), -})); - -describe('hook: useLocks', () => { - it('fetches and returns the locks', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useLocks('cluster-id') - ); - result.current.fetchLocks('cluster-id'); - expect(result.current.locks).toHaveLength(0); - await waitForNextUpdate(); - expect(result.current.locks).toHaveLength(4); - }); - - it('creates locks', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useLocks('cluster-id') - ); - // When the hook is initialized it fetches all hooks so wait for this to - // happen before continuing on. - await waitForNextUpdate(); - const resp = await result.current.createLock('cluster-id', { - targets: { user: 'banned' }, - message: "you've been bad", - ttl: '5h', - }); - expect(resp).toBe(mockHookCreated); - }); -}); diff --git a/web/packages/teleport/src/Locks/useLocks.tsx b/web/packages/teleport/src/Locks/useLocks.tsx deleted file mode 100644 index 7f6612791d0d2..0000000000000 --- a/web/packages/teleport/src/Locks/useLocks.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { useCallback, useEffect, useState } from 'react'; - -import api from 'teleport/services/api'; -import cfg from 'teleport/config'; - -import type { CreateLockData, Lock, LockForTable } from './types'; - -export function useLocks(clusterId: string) { - const [locks, setLocks] = useState([]); - - const fetchLocks = useCallback((clusterId: string) => { - api.get(cfg.getLocksUrl(clusterId)).then((resp: Lock[]) => { - const locksResp = resp.map(lock => ({ - ...lock, - targets: Object.entries(lock.targets).map(([key, value]) => ({ - name: key, - value, - })), - })); - setLocks(locksResp); - }); - }, []); - - const createLock = useCallback( - async (clusterId: string, createLockData: CreateLockData) => { - return await api.put(cfg.getLocksUrl(clusterId), createLockData); - }, - [] - ); - - useEffect(() => { - fetchLocks(clusterId); - }, [clusterId, fetchLocks]); - - return { createLock, fetchLocks, locks }; -} diff --git a/web/packages/teleport/src/LocksV2/Locks/DeleteLockDialogue.tsx b/web/packages/teleport/src/LocksV2/Locks/DeleteLockDialogue.tsx new file mode 100644 index 0000000000000..a299049a539b7 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/Locks/DeleteLockDialogue.tsx @@ -0,0 +1,71 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ButtonSecondary, ButtonWarning, Text, Flex } from 'design'; +import { Danger } from 'design/Alert'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import Dialog, { + DialogHeader, + DialogTitle, + DialogContent, + DialogFooter, +} from 'design/DialogConfirmation'; + +import { Lock } from 'teleport/services/locks'; + +import { Pills } from './Locks'; + +type Props = { + onClose(): void; + onDelete(lockName: string): Promise; + lock: Lock; +}; + +export function DeleteLockDialogue(props: Props) { + const { lock, onClose, onDelete } = props; + const { attempt, run } = useAttempt(''); + const isDisabled = attempt.status === 'processing'; + + function onOk() { + run(() => onDelete(lock.name)); + } + + return ( + + + Delete Lock + + + {attempt.status === 'failed' && {attempt.statusText}} + + + Are you sure you want to delete lock for{' '} + + ? + + + + + Yes, Delete Lock + + + Cancel + + + + ); +} diff --git a/web/packages/teleport/src/LocksV2/Locks/Locks.tsx b/web/packages/teleport/src/LocksV2/Locks/Locks.tsx new file mode 100644 index 0000000000000..d19f473b72249 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/Locks/Locks.tsx @@ -0,0 +1,210 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState, useEffect } from 'react'; +import { useLocation, useHistory } from 'react-router'; +import { formatRelative } from 'date-fns'; +import { Danger } from 'design/Alert'; + +import Table, { Cell } from 'design/DataTable'; +import { ButtonPrimary, Label as Pill } from 'design'; +import useAttempt from 'shared/hooks/useAttemptNext'; + +import cfg from 'teleport/config'; +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout'; +import { NavLink } from 'teleport/components/Router'; +import useTeleport from 'teleport/useTeleport'; + +import { lockService, Lock, LockTarget } from 'teleport/services/locks'; + +import { TrashButton } from '../common'; + +import { DeleteLockDialogue } from './DeleteLockDialogue'; + +export function Locks() { + const ctx = useTeleport(); + const history = useHistory(); + const location = useLocation<{ createdLocks: Lock[] }>(); + const { attempt, run } = useAttempt(); + const [locks, setLocks] = useState([]); + const [lockToDelete, setLockToDelete] = useState(); + + useEffect(() => { + run(() => + lockService.fetchLocks().then(res => { + const updatedLocks = [...res]; + // If location state was set, user is coming back from + // creating a set of locks. Because of possible cache lagging, + // we will manually add missing new locks to the fetched list. + if (location.state?.createdLocks) { + const seenLock = {}; + updatedLocks.forEach(lock => (seenLock[lock.name] = true)); + location.state.createdLocks.forEach(lock => { + if (!seenLock[lock.name]) { + updatedLocks.push(lock); + } + }); + history.replace({ state: {} }); // Clear loc state afterwards. + } + setLocks(updatedLocks); + }) + ); + }, []); + + function deleteLock(lockName: string) { + return lockService.deleteLock(lockName).then(() => { + // Manually remove from the initial fetched + // list since there could be cache lagging. + const updatedLocks = locks.filter(lock => lock.name !== lockName); + setLocks(updatedLocks); + setLockToDelete(null); + }); + } + + const lockAccess = ctx.storeUser.getLockAccess(); + const canCreate = lockAccess.create && lockAccess.edit; + + return ( + <> + + + Session & Identity Locks + + Add New Lock + + + {attempt.status === 'failed' && {attempt.statusText}} +
( + + + + ), + }, + { + key: 'createdBy', + headerText: 'Locked By', + isSortable: true, + }, + { + key: 'createdAt', + headerText: 'Start Date', + isSortable: true, + render: ({ createdAt }) => ( + {getFormattedDate(createdAt)} + ), + }, + { + key: 'expires', + headerText: 'Expiration', + isSortable: true, + render: ({ expires }) => ( + {getFormattedDate(expires) || 'Never'} + ), + }, + { + key: 'message', + headerText: 'Message', + isSortable: true, + render: ({ message }) => {message}, + }, + { + altKey: 'options-btn', + render: lock => ( + + setLockToDelete(lock)} /> + + ), + }, + ]} + emptyText="No Locks Found" + isSearchable + customSearchMatchers={[lockTargetsMatcher]} + pagination={{ pageSize: 20 }} + fetching={{ + fetchStatus: attempt.status === 'processing' ? 'loading' : '', + }} + /> + + {lockToDelete && ( + setLockToDelete(null)} + onDelete={deleteLock} + lock={lockToDelete} + /> + )} + + ); +} + +function getFormattedDate(d: string): string { + try { + return formatRelative(new Date(d), Date.now()); + } catch (e) { + return ''; + } +} + +function lockTargetsMatcher( + targetValue: any, + searchValue: string, + propName: keyof Lock & string +) { + if (propName === 'targets') { + return targetValue.some( + ({ name, value }) => + name.toLocaleUpperCase().includes(searchValue) || + value.toLocaleUpperCase().includes(searchValue) || + `${name}: ${value}`.toLocaleUpperCase().includes(searchValue) + ); + } +} + +export function Pills({ targets }: { targets: LockTarget[] }) { + const pills = targets.map((target, index) => { + const labelText = `${target.kind}: ${target.name}`; + return ( + + {labelText} + + ); + }); + + return {pills}; +} diff --git a/web/packages/teleport/src/LocksV2/Locks/index.ts b/web/packages/teleport/src/LocksV2/Locks/index.ts new file mode 100644 index 0000000000000..9345d34db3ce3 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/Locks/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// export as default for use with React.lazy +export { Locks as default } from './Locks'; diff --git a/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/LockCheckout.tsx b/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/LockCheckout.tsx new file mode 100644 index 0000000000000..f828b88a4ed66 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/LockCheckout.tsx @@ -0,0 +1,385 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useRef, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { + Box, + Flex, + ButtonText, + ButtonPrimary, + Image, + Text, + Alert, + Input, +} from 'design'; +import { ArrowBack } from 'design/Icon'; +import Table, { Cell } from 'design/DataTable'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import { pluralize } from 'shared/utils/text'; + +import cfg from 'teleport/config'; +import { lockService } from 'teleport/services/locks'; +import { TrashButton } from 'teleport/LocksV2/common'; + +import { + LockResource, + LockResourceKind, + LockResourceMap, + ToggleSelectResourceFn, +} from '../common'; + +import shieldCheck from './shield-check.png'; + +import type { TransitionStatus } from 'react-transition-group'; + +type Props = { + onClose(): void; + selectedResources: LockResourceMap; + toggleResource: ToggleSelectResourceFn; + selectedResourceKind: LockResourceKind; + batchDeleteResources(resources: LockResource[]): void; + reset(): void; + transitionState: TransitionStatus; +}; + +export function LockCheckout({ + selectedResources, + onClose, + reset, + toggleResource, + transitionState, + batchDeleteResources, +}: Props) { + const sliderRef = useRef(); + + const { attempt, setAttempt } = useAttempt(''); + + const [message, setMessage] = useState(''); + const [ttl, setTtl] = useState(''); + const [createdLocks, setCreatedLocks] = useState([]); + + // Format data suitable for table listing. + const locks: LockResource[] = []; + const resourceKeys = Object.keys(selectedResources) as LockResourceKind[]; + resourceKeys.forEach(kind => { + Object.keys(selectedResources[kind]).forEach(targetValue => + locks.push({ + kind, + targetValue, + friendlyName: selectedResources[kind][targetValue], + }) + ); + }); + + function createLocks() { + setAttempt({ status: 'processing' }); + + // Each lock is a separate fetch request and + // not every request is gauranteed to be successful (eg. network blip). + // We will allow every request to run and then check for failures. + // Any failures will be reported to user so they can attempt to create + // locks again just for the failed ones. + const promises = locks.map(lock => { + return lockService.createLock({ + targets: { [lock.kind]: lock.targetValue }, + message, + ttl, + }); + }); + + return Promise.allSettled(promises) + .then(results => { + const rejectedReasons: string[] = []; + const resourcesToRemove: LockResource[] = []; + + results.forEach((res, index) => { + if (res.status === 'fulfilled') { + createdLocks.push(res.value); + resourcesToRemove.push(locks[index]); + } else { + rejectedReasons.push(res.reason); + } + }); + + setCreatedLocks(createdLocks); + if (rejectedReasons.length > 0) { + // Batch remove the ones we successfully created so users + // don't see it from the list anymore and we don't duplicate + // lock requests. + batchDeleteResources(resourcesToRemove); + setAttempt({ + status: 'failed', + // Only show the first error, most likely the rest of the errors will be the same. + statusText: `some resources failed to lock (see table below), try again: ${rejectedReasons[0]}`, + }); + } else { + reset(); + setAttempt({ status: 'success' }); + } + }) + .catch((err: Error) => { + // Should never reach here, but just in case. + setAttempt({ + status: 'failed', + statusText: err?.message || 'failed to create any locks', + }); + }); + } + + function deleteLock(resource: LockResource) { + setAttempt({ status: '' }); + toggleResource(resource); + } + + const submitBtnDisabled = + locks.length === 0 || attempt.status === 'processing'; + + // Listeners are attached to enable overflow on the parent container after + // transitioning ends (entered) or starts (exits). Enables vertical scrolling + // when content gets too big. + // + // Overflow is initially hidden to prevent + // brief flashing of horizontal scroll bar resulting from positioning + // the container off screen to the right for the slide affect. + useEffect(() => { + function applyOverflowAutoStyle(e: TransitionEvent) { + if (e.propertyName === 'right') { + sliderRef.current.style.overflow = `auto`; + // There will only ever be one 'end right' transition invoked event, so we remove it + // afterwards, and listen for the 'start right' transition which is only invoked + // when user exits this component. + window.removeEventListener('transitionend', applyOverflowAutoStyle); + window.addEventListener('transitionstart', applyOverflowHiddenStyle); + } + } + + function applyOverflowHiddenStyle(e: TransitionEvent) { + if (e.propertyName === 'right') { + sliderRef.current.style.overflow = `hidden`; + } + } + + window.addEventListener('transitionend', applyOverflowAutoStyle); + + return () => { + window.removeEventListener('transitionend', applyOverflowAutoStyle); + window.removeEventListener('transitionstart', applyOverflowHiddenStyle); + }; + }, []); + + return ( + + + + {attempt.status === 'success' ? ( + + + + Resources Locked Successfully + + + You've successfully locked {createdLocks.length}{' '} + {pluralize(createdLocks.length, 'resource')} + + + + + + + ) : ( + + + + + {locks.length} {pluralize(locks.length, 'Target')} Added + + + + )} + {attempt.status === 'success' ? ( + + ) : ( + <> + {attempt.status === 'failed' && ( + + )} + ( + + deleteLock(lock)} + disabled={attempt.status === 'processing'} + /> + + ), + }, + ]} + emptyText="No lock targets are selected" + /> + + Message + setMessage(e.currentTarget.value)} + /> + + + TTL + setTtl(e.currentTarget.value)} + /> + + + theme.colors.levels.sunken}; + `} + > + + Create Locks + + + + )} + + + ); +} + +function SuccessActionComponent({ reset, onClose, locks }) { + return ( + + + Back to Locks + + { + reset(); + onClose(); + }} + > + Make Another Request + + + ); +} + +const SidePanel = styled(Box)` + position: absolute; + z-index: 11; + top: 0px; + right: 0px; + background: ${({ theme }) => theme.colors.levels.sunken}; + min-height: 100%; + width: 500px; + padding: 20px; + + &.entering { + right: -500px; + } + &.entered { + right: 0px; + transition: right 300ms ease-out; + } + &.exiting { + right: -500px; + transition: right 300ms ease-out; + } + &.exited { + right: -500px; + } +`; + +const Dimmer = styled(Box)` + background: #000; + opacity: 0.5; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; +`; + +const StyledTable = styled(Table)` + & > tbody > tr > td { + vertical-align: middle; + } + & > thead > tr > th { + background: ${props => props.theme.colors.spotBackground[1]}; + } + border-radius: 8px; + box-shadow: ${props => props.theme.boxShadow[0]}; + overflow: hidden; +` as typeof Table; diff --git a/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/index.ts b/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/index.ts new file mode 100644 index 0000000000000..2c6b84730814e --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// export as default for use with React.lazy +export { LockCheckout as default } from './LockCheckout'; diff --git a/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/shield-check.png b/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/shield-check.png new file mode 100644 index 0000000000000000000000000000000000000000..98d8f85c2c7661e025bc4b9c0e7d0f6723e59c98 GIT binary patch literal 38458 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 mm_LQ_cNzF!iTM8w{j|=( + getEmptyResourceMap() + ); + + function clearSelectedResources() { + setSelectedResources(getEmptyResourceMap()); + } + + // toggleSelectResource adds to selection map if it doesn't exist, + // else removes it from the map. + function toggleSelectResource(resource: LockResource) { + const { kind, targetValue, friendlyName } = resource; + const newMap = copySelectedResources(); + if (newMap[kind][targetValue]) { + delete newMap[kind][targetValue]; + } else { + newMap[kind][targetValue] = friendlyName || targetValue; + } + + setSelectedResources(newMap); + } + + function copySelectedResources() { + const copy = {} as LockResourceMap; + const kinds = Object.keys(selectedResources); + kinds.forEach(kind => (copy[kind] = { ...selectedResources[kind] })); + + return copy; + } + + function batchDeleteResources(resources: LockResource[]) { + const newMap = copySelectedResources(); + resources.forEach(r => { + const { kind, targetValue } = r; + + if (newMap[kind][targetValue]) { + delete newMap[kind][targetValue]; + } + }); + setSelectedResources(newMap); + } + + function updateResourceOption(newOpt: LockResourceOption) { + setSelectedResourceOpt(newOpt); + + // There is no fetching for logins, so turn off the attempt state. + if (newOpt.value === 'login') { + setAttempt({ status: '' }); + return; + } + + // All others will require fetching on init, so reset the + // attempt state to processing. + if (newOpt.listKind !== selectedResourceOpt.listKind) { + setAttempt({ status: 'processing' }); + } + } + + const selectedResourceKind = selectedResourceOpt.value; + const commonListProps: CommonListProps = { + pageSize: PAGE_SIZE, + attempt, + setAttempt, + selectedResourceKind: selectedResourceKind, + selectedResources: selectedResources, + toggleSelectResource, + }; + + let content; + switch (selectedResourceOpt.listKind) { + case 'simple': + content = ; + break; + case 'hybrid': + content = ; + break; + case 'logins': + content = ( + + ); + break; + case 'server-side': + content = ; + break; + default: + console.error( + `[NewLock.tsx] listKind ${selectedResourceOpt.listKind} not defined` + ); + return; // don't render anything on error + } + + const numAddedResources = getNumSelectedResources(selectedResources); + return ( + + + + + + New Lock Request + + + + + {attempt.status === 'failed' && ( + + )} + + setLoginInput(e.currentTarget.value)} + /> + + + Add Login + + +
+ renderActionCell( + Boolean(props.selectedResources.login[login]), + () => + props.toggleSelectResource({ + kind: 'login', + targetValue: login, + }) + ), + }, + ]} + emptyText="No Logins Added Yet" + pagination={{ pageSize: props.pageSize }} + isSearchable + /> + + ); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Desktops.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Desktops.tsx new file mode 100644 index 0000000000000..b712601cdc8e2 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Desktops.tsx @@ -0,0 +1,74 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ClickableLabelCell } from 'design/DataTable'; + +import { Desktop } from 'teleport/services/desktops'; + +import { ServerSideListProps, StyledTable, renderActionCell } from '../common'; + +export function Desktops(props: ServerSideListProps & { desktops: Desktop[] }) { + const { + desktops = [], + selectedResources, + customSort, + onLabelClick, + toggleSelectResource, + } = props; + + return ( + ( + + ), + }, + { + altKey: 'action-btn', + render: agent => + renderActionCell( + Boolean(selectedResources.windows_desktop[agent.name]), + () => + toggleSelectResource({ + kind: 'windows_desktop', + targetValue: agent.name, + }) + ), + }, + ]} + emptyText="No Desktops Found" + customSort={customSort} + disableFilter + fetching={{ + fetchStatus: props.fetchStatus, + }} + /> + ); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Nodes.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Nodes.tsx new file mode 100644 index 0000000000000..9256056d44d61 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/Nodes.tsx @@ -0,0 +1,87 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Cell, ClickableLabelCell } from 'design/DataTable'; + +import { Node } from 'teleport/services/nodes'; + +import { ServerSideListProps, StyledTable, renderActionCell } from '../common'; + +export function Nodes(props: ServerSideListProps & { nodes: Node[] }) { + const { + nodes = [], + selectedResources, + customSort, + onLabelClick, + toggleSelectResource, + } = props; + + return ( + ( + + ), + }, + { + altKey: 'action-btn', + render: agent => + renderActionCell(Boolean(selectedResources.node[agent.id]), () => + toggleSelectResource({ + kind: 'node', + targetValue: agent.id, + friendlyName: agent.hostname, + }) + ), + }, + ]} + emptyText="No Nodes Found" + customSort={customSort} + disableFilter + fetching={{ + fetchStatus: props.fetchStatus, + }} + /> + ); +} + +export const renderAddressCell = ({ addr, tunnel }: Node) => ( + {tunnel ? renderTunnel() : addr} +); + +function renderTunnel() { + return ( + {`⟵ tunnel`} + ); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx new file mode 100644 index 0000000000000..14e3214592fef --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/ServerSideSupportedList/ServerSideSupportedList.tsx @@ -0,0 +1,211 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { SortType } from 'design/DataTable/types'; +import { Flex } from 'design'; +import { StyledPanel } from 'design/DataTable/StyledTable'; +import { SearchPanel } from 'shared/components/Search'; +import { StyledArrowBtn } from 'design/DataTable/Pager/StyledPager'; +import { CircleArrowLeft, CircleArrowRight } from 'design/Icon'; + +import { Desktop } from 'teleport/services/desktops'; +import { Node } from 'teleport/services/nodes'; +import { useServerSidePagination } from 'teleport/components/hooks'; +import useTeleport from 'teleport/useTeleport'; +import cfg from 'teleport/config'; +import Ctx from 'teleport/teleportContext'; + +import { TableWrapper, ServerSideListProps } from '../common'; +import { CommonListProps, LockResourceKind } from '../../common'; + +import { Nodes } from './Nodes'; +import { Desktops } from './Desktops'; + +import type { AgentLabel, AgentFilter } from 'teleport/services/agents'; + +export function ServerSideSupportedList(props: CommonListProps) { + const ctx = useTeleport(); + + const [resourceFilter, setResourceFilter] = useState({}); + + const { + fetchStatus, + fetchNext, + fetchPrev, + fetch, + attempt: fetchAttempt, + pageIndicators, + fetchedData, + } = useServerSidePagination({ + fetchFunc: getFetchFuncForServerSidePaginating( + ctx, + props.selectedResourceKind + ) as any, + clusterId: cfg.proxyCluster, // Locking only supported with root cluster + params: resourceFilter, + pageSize: props.pageSize, + }); + + useEffect(() => { + // Resetting the filter will trigger a fetch. + setResourceFilter({ + sort: getDefaultSort(props.selectedResourceKind), + search: '', + query: '', + }); + }, [props.selectedResourceKind]); + + useEffect(() => { + fetch(); + }, [resourceFilter]); + + useEffect(() => { + props.setAttempt(fetchAttempt); + }, [fetchAttempt]); + + function updateSort(sort: SortType) { + setResourceFilter({ ...resourceFilter, sort }); + } + + function updateSearch(search: string) { + setResourceFilter({ ...resourceFilter, query: '', search }); + } + + function updateQuery(query: string) { + setResourceFilter({ ...resourceFilter, search: '', query }); + } + + function onResourceLabelClick(label: AgentLabel) { + const query = addResourceLabelToQuery(resourceFilter, label); + setResourceFilter({ ...resourceFilter, search: '', query }); + } + + const table = useMemo(() => { + const listProps: ServerSideListProps = { + fetchStatus, + customSort: { + dir: resourceFilter.sort?.dir, + fieldName: resourceFilter.sort?.fieldName, + onSort: updateSort, + }, + onLabelClick: onResourceLabelClick, + selectedResources: props.selectedResources, + toggleSelectResource: props.toggleSelectResource, + }; + + switch (props.selectedResourceKind) { + case 'node': + return ; + case 'windows_desktop': + return ( + + ); + default: + console.error( + `[ServerSideSupportedList.tsx] table not defined for resource kind ${props.selectedResourceKind}` + ); + } + }, [props.attempt, fetchedData, fetchStatus, props.selectedResources]); + + return ( + props.theme.boxShadow[0]}; + `} + > + + {table} + + + + + + + + + + + + + + + ); +} + +function getDefaultSort(kind: LockResourceKind): SortType { + if (kind === 'node') { + return { fieldName: 'hostname', dir: 'ASC' }; + } + return { fieldName: 'name', dir: 'ASC' }; +} + +function getFetchFuncForServerSidePaginating( + ctx: Ctx, + resourceKind: LockResourceKind +) { + if (resourceKind === 'node') { + return ctx.nodeService.fetchNodes; + } + + if (resourceKind === 'windows_desktop') { + return ctx.desktopService.fetchDesktops; + } +} + +function addResourceLabelToQuery(filter: AgentFilter, label: AgentLabel) { + const queryParts = []; + + // Add existing query + if (filter.query) { + queryParts.push(filter.query); + } + + // If there is an existing simple search, + // convert it to predicate language and add it + if (filter.search) { + queryParts.push(`search("${filter.search}")`); + } + + // Create the label query. + queryParts.push(`labels["${label.name}"] == "${label.value}"`); + + return queryParts.join(' && '); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/MfaDevices.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/MfaDevices.tsx new file mode 100644 index 0000000000000..0cd6dc8e7a99c --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/MfaDevices.tsx @@ -0,0 +1,106 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Text } from 'design'; +import Table, { Cell } from 'design/DataTable'; +import { dateMatcher } from 'design/utils/match'; +import { displayDate } from 'shared/services/loc'; + +import { MfaDevice } from 'teleport/services/mfa/types'; + +import { renderActionCell, SimpleListProps } from '../common'; + +export function MfaDevices( + props: SimpleListProps & { mfaDevices: MfaDevice[] } +) { + const { + mfaDevices = [], + selectedResources, + toggleSelectResource, + fetchStatus, + } = props; + + return ( +
( + {displayDate(registeredDate)} + ), + }, + { + key: 'lastUsedDate', + headerText: 'Last Used', + isSortable: true, + render: ({ lastUsedDate }) => ( + {displayDate(lastUsedDate)} + ), + }, + { + altKey: 'action-btn', + render: ({ name, id }) => + renderActionCell(Boolean(selectedResources.mfa_device[id]), () => + toggleSelectResource({ + kind: 'mfa_device', + targetValue: id, + friendlyName: name, + }) + ), + }, + ]} + emptyText="No Devices Found" + isSearchable + initialSort={{ + key: 'registeredDate', + dir: 'DESC', + }} + customSearchMatchers={[dateMatcher(['registeredDate', 'lastUsedDate'])]} + pagination={{ pageSize: props.pageSize }} + fetching={{ + fetchStatus, + }} + /> + ); +} + +const renderNameCell = ({ name }: MfaDevice) => { + return ( + + + {name} + + + ); +}; diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx new file mode 100644 index 0000000000000..d1b5794bec13a --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Roles.tsx @@ -0,0 +1,58 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Table from 'design/DataTable'; + +import { Resource, KindRole } from 'teleport/services/resources'; + +import { renderActionCell, SimpleListProps } from '../common'; + +export function Roles( + props: SimpleListProps & { roles: Resource[] } +) { + const { + roles = [], + selectedResources, + toggleSelectResource, + fetchStatus, + } = props; + + return ( +
+ renderActionCell(Boolean(selectedResources.role[name]), () => + toggleSelectResource({ kind: 'role', targetValue: name }) + ), + }, + ]} + emptyText="No Roles Found" + pagination={{ pageSize: props.pageSize }} + isSearchable + fetching={{ + fetchStatus, + }} + /> + ); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx new file mode 100644 index 0000000000000..bf6b4c06e9f57 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/SimpleList.tsx @@ -0,0 +1,118 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect, useMemo } from 'react'; + +import useTeleport from 'teleport/useTeleport'; +import { User } from 'teleport/services/user'; +import { MfaDevice } from 'teleport/services/mfa'; +import { KindRole, Resource } from 'teleport/services/resources'; + +import { TableWrapper, SimpleListProps } from '../common'; +import { CommonListProps, LockResourceKind } from '../../common'; + +import { Roles } from './Roles'; +import Users from './Users'; +import { MfaDevices } from './MfaDevices'; + +export type SimpleListOpts = { + getFetchFn(selectedResourceKind: LockResourceKind): (p: any) => Promise; + getTable( + selectedResourceKind: LockResourceKind, + resources: any[], + listProps: SimpleListProps + ): React.ReactElement; +}; + +export function SimpleList(props: CommonListProps & { opts: SimpleListOpts }) { + const ctx = useTeleport(); + const [resources, setResources] = useState([]); + + useEffect(() => { + let fetchFn; + switch (props.selectedResourceKind) { + case 'role': + fetchFn = ctx.resourceService.fetchRoles; + break; + case 'user': + fetchFn = ctx.userService.fetchUsers; + break; + case 'mfa_device': + fetchFn = ctx.mfaService.fetchDevices; + break; + default: + fetchFn = props.opts?.getFetchFn(props.selectedResourceKind); + if (!fetchFn) { + console.error( + `[SimpleList.tsx] fetchFn not defined for resource kind ${props.selectedResourceKind}` + ); + return; // don't do anything on error + } + } + + setResources([]); + props.setAttempt({ status: 'processing' }); + fetchFn() + .then(res => { + setResources(res); + props.setAttempt({ status: 'success' }); + }) + .catch((err: Error) => { + props.setAttempt({ status: 'failed', statusText: err.message }); + }); + }, [props.selectedResourceKind]); + + const table = useMemo(() => { + const listProps: SimpleListProps = { + pageSize: props.pageSize, + fetchStatus: props.attempt.status === 'processing' ? 'loading' : '', + selectedResources: props.selectedResources, + toggleSelectResource: props.toggleSelectResource, + }; + switch (props.selectedResourceKind) { + case 'role': + return ( + []} {...listProps} /> + ); + case 'user': + return ; + case 'mfa_device': + return ( + + ); + default: + const table = props.opts?.getTable( + props.selectedResourceKind, + resources, + listProps + ); + if (table) { + return table; + } + console.error( + `[SimpleList.tsx] table not defined for resource kind ${props.selectedResourceKind}` + ); + } + }, [props.attempt, resources, props.selectedResources]); + + return ( + + {table} + + ); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Users.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Users.tsx new file mode 100644 index 0000000000000..8c688162f7194 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/SimpleList/Users.tsx @@ -0,0 +1,99 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Table, { Cell, LabelCell } from 'design/DataTable'; + +import { User } from 'teleport/services/user'; + +import { renderActionCell, SimpleListProps } from '../common'; + +export default function UserList(props: SimpleListProps & { users: User[] }) { + const { + users = [], + selectedResources, + toggleSelectResource, + fetchStatus, + } = props; + + return ( +
{ + const aStr = a.toString(); + const bStr = b.toString(); + + if (aStr < bStr) { + return -1; + } + if (aStr > bStr) { + return 1; + } + + return 0; + }, + render: ({ roles }) => , + }, + { + key: 'authType', + headerText: 'Type', + isSortable: true, + render: ({ authType }) => ( + + {renderAuthType(authType)} + + ), + }, + { + altKey: 'action-btn', + render: ({ name }) => + renderActionCell(Boolean(selectedResources.user[name]), () => + toggleSelectResource({ kind: 'user', targetValue: name }) + ), + }, + ]} + emptyText="No Users Found" + isSearchable + pagination={{ pageSize: props.pageSize }} + fetching={{ + fetchStatus, + }} + /> + ); + + // TODO(lisa): do properly + function renderAuthType(authType: string) { + switch (authType) { + case 'github': + return 'GitHub'; + case 'saml': + return 'SAML'; + case 'oidc': + return 'OIDC'; + } + return authType; + } +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/common.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/common.tsx new file mode 100644 index 0000000000000..609e49a9e7e19 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/common.tsx @@ -0,0 +1,91 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import styled from 'styled-components'; +import { Box, ButtonPrimary, ButtonBorder } from 'design'; +import Table, { Cell } from 'design/DataTable'; +import { + CustomSort, + FetchStatus, + LabelDescription, +} from 'design/DataTable/types'; + +import { LockResourceMap, ToggleSelectResourceFn } from '../common'; + +export type ServerSideListProps = { + fetchStatus: FetchStatus; + customSort: CustomSort; + onLabelClick(label: LabelDescription): void; + selectedResources: LockResourceMap; + toggleSelectResource: ToggleSelectResourceFn; +}; + +export type SimpleListProps = { + pageSize: number; + fetchStatus: FetchStatus; + selectedResources: LockResourceMap; + toggleSelectResource: ToggleSelectResourceFn; +}; + +export type LoginsProps = { + pageSize: number; + selectedResources: LockResourceMap; + toggleSelectResource: ToggleSelectResourceFn; +}; + +export type HybridListProps = { + pageSize: number; + selectedResources: LockResourceMap; + toggleSelectResource: ToggleSelectResourceFn; + fetchNextPage(): void; + fetchStatus: FetchStatus; +}; + +export const StyledTable = styled(Table)` + & > tbody > tr > td { + vertical-align: middle; + } +` as typeof Table; + +export const TableWrapper = styled(Box)` + &.disabled { + pointer-events: none; + opacity: 0.5; + } +`; + +export function renderActionCell( + isResourceSelected: boolean, + toggleResourceSelect: () => void +) { + return ( + + {isResourceSelected ? ( + + Remove + + ) : ( + + + Add Target + + )} + + ); +} diff --git a/web/packages/teleport/src/LocksV2/NewLock/common.tsx b/web/packages/teleport/src/LocksV2/NewLock/common.tsx new file mode 100644 index 0000000000000..8116a1c8636aa --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/common.tsx @@ -0,0 +1,128 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { State as AttemptState } from 'shared/hooks/useAttemptNext'; + +export type LockResource = { + kind: LockResourceKind; + // targetValue is the value used + // in making a lock request. + targetValue: string; + // friendlyName is the name that the user + // will see on the screen instead of the + // targetValue if defined (eg: instead of showing user + // node id, we show node hostname which is easier to read) + friendlyName?: string; +}; + +export type ToggleSelectResourceFn = (resource: LockResource) => void; + +export type CommonListProps = { + pageSize: number; + attempt: AttemptState['attempt']; + setAttempt: AttemptState['setAttempt']; + selectedResourceKind: LockResourceKind; + selectedResources: LockResourceMap; + toggleSelectResource: ToggleSelectResourceFn; +}; + +// ResourceKind describes which resource kinds can be locked. +export type LockResourceKind = + | 'user' + | 'role' + | 'login' + | 'node' + | 'mfa_device' + | 'windows_desktop' + | 'access_request' + | 'device'; // trusted devices + +type TargetValue = string; +type FriendlyName = string; + +// ResourceMap will be used to keep track of all the resource +// name the user selects to lock. +export type LockResourceMap = { + [K in LockResourceKind]: Record; +}; + +export function getEmptyResourceMap(): LockResourceMap { + return { + node: {}, + windows_desktop: {}, + role: {}, + user: {}, + mfa_device: {}, + login: {}, + access_request: {}, + device: {}, + }; +} + +type ListKind = + // simple refers to lists where paginating and filtering are handled + // on the client side. Resources like users, roles, mfa devices, + // access requests still retrieve everything up front. + | 'simple' + // hybrid refers to lists with partial server side support for + // paging (supply start key and limit) and the unsupported + // filtering/searching is done on the client side. + | 'hybrid' + // server-side refers to lists with pure server side paginating and + // filtering support (eg: nodes, databases, desktops, etc.) + | 'server-side' + // logins is special in that we can't fetch logins from the back. + // this kind of list requires manual input from users. + | 'logins'; + +export type LockResourceOption = { + value: LockResourceKind; + label: string; + listKind: ListKind; +}; + +export const baseResourceKindOpts: LockResourceOption[] = [ + { + value: 'user', + label: 'Users', + listKind: 'simple', + }, + { + value: 'role', + label: 'Roles', + listKind: 'simple', + }, + { + value: 'mfa_device', + label: 'MFA Devices', + listKind: 'simple', + }, + { + value: 'login', + label: 'Logins', + listKind: 'logins', + }, + { + value: 'node', + label: 'Servers', + listKind: 'server-side', + }, + { + value: 'windows_desktop', + label: 'Desktops', + listKind: 'server-side', + }, +]; diff --git a/web/packages/teleport/src/LocksV2/NewLock/index.ts b/web/packages/teleport/src/LocksV2/NewLock/index.ts new file mode 100644 index 0000000000000..178f14ee867bb --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// export as default for use with React.lazy +import NewLockWrapper from './NewLock'; +export default NewLockWrapper; diff --git a/web/packages/teleport/src/LocksV2/common.tsx b/web/packages/teleport/src/LocksV2/common.tsx new file mode 100644 index 0000000000000..2205b1437c94a --- /dev/null +++ b/web/packages/teleport/src/LocksV2/common.tsx @@ -0,0 +1,29 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import styled from 'styled-components'; +import { Trash } from 'design/Icon'; + +export const TrashButton = styled(Trash)` + padding: 8px; + font-size: 13px; + border-radius: 2px; + cursor: pointer; + background-color: ${({ theme }) => theme.colors.buttons.trashButton.default}; + :hover { + background-color: ${({ theme }) => theme.colors.buttons.trashButton.hover}; + } +`; diff --git a/web/packages/teleport/src/components/hooks/useServersidePagination.ts b/web/packages/teleport/src/components/hooks/useServersidePagination.ts index 72ab4212fe72f..075900696eedf 100644 --- a/web/packages/teleport/src/components/hooks/useServersidePagination.ts +++ b/web/packages/teleport/src/components/hooks/useServersidePagination.ts @@ -51,6 +51,7 @@ export function useServerSidePagination({ } function fetch() { + setFetchStatus('loading'); setAttempt({ status: 'processing' }); fetchFunc(clusterId, { ...params, limit: pageSize }) .then(res => { @@ -70,6 +71,7 @@ export function useServerSidePagination({ .catch((err: Error) => { setAttempt({ status: 'failed', statusText: err.message }); setFetchedData({ ...fetchedData, agents: [], totalCount: 0 }); + setFetchStatus(''); }); } diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index b8a2bb054f846..d8798965e218f 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -99,8 +99,6 @@ const cfg = { consoleConnect: '/web/cluster/:clusterId/console/node/:serverId/:login', consoleSession: '/web/cluster/:clusterId/console/session/:sid', player: '/web/cluster/:clusterId/session/:sid', // ?recordingType=ssh|desktop|k8s&durationMs=1234 - locks: '/web/cluster/:clusterId/locks', - newLock: '/web/cluster/:clusterId/locks/new', login: '/web/login', loginSuccess: '/web/msg/info/login_success', loginErrorLegacy: '/web/msg/error/login_failed', @@ -115,6 +113,8 @@ const cfg = { headlessSso: `/web/headless/:requestId`, integrations: '/web/integrations', integrationEnroll: '/web/integrations/new/:type?', + locks: '/web/locks', + newLock: '/web/locks/new', // whitelist sso handlers oidcHandler: '/v1/webapi/oidc/*', @@ -514,19 +514,23 @@ const cfg = { }); }, - getLocksRoute(clusterId: string) { - return generatePath(cfg.routes.locks, { clusterId }); + getLocksRoute() { + return cfg.routes.locks; }, - getNewLocksRoute(clusterId: string) { - return generatePath(cfg.routes.newLock, { clusterId }); + getNewLocksRoute() { + return cfg.routes.newLock; }, - getLocksUrl(clusterId: string) { + getLocksUrl() { + // Currently only support get/create locks in root cluster. + const clusterId = cfg.proxyCluster; return generatePath(cfg.api.locksPath, { clusterId }); }, - getLocksUrlWithUuid(clusterId: string, uuid: string) { + getLocksUrlWithUuid(uuid: string) { + // Currently only support delete/lookup locks in root cluster. + const clusterId = cfg.proxyCluster; return generatePath(cfg.api.locksPathWithUuid, { clusterId, uuid }); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 18effb253b246..55a410e25b266 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -86,10 +86,10 @@ const AuthConnectors = React.lazy( () => import(/* webpackChunkName: "auth-connectors" */ './AuthConnectors') ); const Locks = React.lazy( - () => import(/* webpackChunkName: "locks" */ './Locks') + () => import(/* webpackChunkName: "locks" */ './LocksV2/Locks') ); const NewLock = React.lazy( - () => import(/* webpackChunkName: "newLock" */ './Locks/NewLock') + () => import(/* webpackChunkName: "newLock" */ './LocksV2/NewLock') ); const Databases = React.lazy( () => import(/* webpackChunkName: "databases" */ './Databases') @@ -344,22 +344,22 @@ export class FeatureLocks implements TeleportFeature { section = ManagementSection.Access; route = { - title: 'Session & Identity Locks', + title: 'Manage Session & Identity Locks', path: cfg.routes.locks, exact: true, component: Locks, }; - hasAccess() { - return true; + hasAccess(flags: FeatureFlags) { + return flags.locks; } navigationItem = { title: 'Session & Identity Locks', icon: , exact: false, - getLink(clusterId: string) { - return cfg.getLocksRoute(clusterId); + getLink() { + return cfg.getLocksRoute(); }, }; } @@ -372,8 +372,14 @@ export class FeatureNewLock implements TeleportFeature { component: NewLock, }; - hasAccess() { - return true; + hasAccess(flags: FeatureFlags) { + return flags.newLocks; + } + + // getRoute allows child class extending this + // parent class to refer to this parent's route. + getRoute() { + return this.route; } } diff --git a/web/packages/teleport/src/mocks/contexts.ts b/web/packages/teleport/src/mocks/contexts.ts index c38a5aaf6e9e1..9cde8df1f1a52 100644 --- a/web/packages/teleport/src/mocks/contexts.ts +++ b/web/packages/teleport/src/mocks/contexts.ts @@ -62,6 +62,7 @@ const allAccessAcl: Acl = { plugins: fullAccess, integrations: { ...fullAccess, use: true }, deviceTrust: fullAccess, + lock: fullAccess, }; export function getAcl(cfg?: { noAccess: boolean }) { diff --git a/web/packages/teleport/src/services/locks/index.ts b/web/packages/teleport/src/services/locks/index.ts new file mode 100644 index 0000000000000..e8ca2873bbe18 --- /dev/null +++ b/web/packages/teleport/src/services/locks/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { lockService } from './locks'; +export * from './types'; diff --git a/web/packages/teleport/src/services/locks/locks.ts b/web/packages/teleport/src/services/locks/locks.ts new file mode 100644 index 0000000000000..f25751ee587e2 --- /dev/null +++ b/web/packages/teleport/src/services/locks/locks.ts @@ -0,0 +1,78 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import api from 'teleport/services/api'; +import cfg from 'teleport/config'; + +import { CreateLockRequest, Lock } from './types'; + +export const lockService = { + fetchLocks(): Promise { + return api.get(cfg.getLocksUrl()).then(makeLocks); + }, + + createLock(req: CreateLockRequest): Promise { + return api.put(cfg.getLocksUrl(), req).then(makeLock); + }, + + deleteLock(id: string): Promise { + return api.delete(cfg.getLocksUrlWithUuid(id)); + }, +}; + +export function makeLocks(json: any): Lock[] { + json = json || []; + return json.map(makeLock); +} + +function makeLock(json: any): Lock { + json = json || {}; + const { + name, + message, + expires, + createdAt, + createdBy, + targets: targetLookup, + } = json; + + let targets = []; + if (targets) { + targets = Object.entries(targetLookup).map(([key, value]) => ({ + kind: key, + name: value, + })); + } + + return { + name, + message, + expires, + createdAt, + createdBy, + targets, + targetLookup: { + user: targetLookup?.user, + role: targetLookup?.role, + login: targetLookup?.login, + node: targetLookup?.node, + mfa_device: targetLookup?.mfa_device, + windows_desktop: targetLookup?.windows_desktop, + device: targetLookup?.device, + access_request: targetLookup?.access_request, + }, + }; +} diff --git a/web/packages/teleport/src/services/locks/types.ts b/web/packages/teleport/src/services/locks/types.ts new file mode 100644 index 0000000000000..1e9a500c995e6 --- /dev/null +++ b/web/packages/teleport/src/services/locks/types.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// LockKind is the expected backend value +// to define the type of resource to be locked. +export type LockKind = + | 'user' + | 'role' + | 'login' + | 'node' + | 'mfa_device' + | 'windows_desktop' + | 'access_request' + | 'device'; // trusted devices + +export type Lock = { + name: string; + message: string; + expires: string; + createdAt: string; + createdBy: string; + targetLookup: Partial>; + targets: LockTarget[]; +}; + +export type LockTarget = { + kind: LockKind; + name: string; +}; + +export type CreateLockRequest = { + targets: Partial>; + message: string; + ttl: string; +}; diff --git a/web/packages/teleport/src/services/user/makeAcl.ts b/web/packages/teleport/src/services/user/makeAcl.ts index 9f15b8a91692d..7ad530f523222 100644 --- a/web/packages/teleport/src/services/user/makeAcl.ts +++ b/web/packages/teleport/src/services/user/makeAcl.ts @@ -30,6 +30,7 @@ export function makeAcl(json): Acl { const tokens = json.tokens || defaultAccess; const accessRequests = json.accessRequests || defaultAccess; const billing = json.billing || defaultAccess; + const lock = json.lock || defaultAccess; const plugins = json.plugins || defaultAccess; const integrations = json.integrations || defaultAccessWithUse; const dbServers = json.dbServers || defaultAccess; @@ -83,6 +84,7 @@ export function makeAcl(json): Acl { license, download, deviceTrust, + lock, }; } diff --git a/web/packages/teleport/src/services/user/types.ts b/web/packages/teleport/src/services/user/types.ts index b47cbd72a76f6..1c9b400751e8f 100644 --- a/web/packages/teleport/src/services/user/types.ts +++ b/web/packages/teleport/src/services/user/types.ts @@ -77,6 +77,7 @@ export interface Acl { plugins: Access; integrations: AccessWithUse; deviceTrust: Access; + lock: Access; } export interface User { diff --git a/web/packages/teleport/src/services/user/user.test.ts b/web/packages/teleport/src/services/user/user.test.ts index 6d46336292c9a..8f25165181fb3 100644 --- a/web/packages/teleport/src/services/user/user.test.ts +++ b/web/packages/teleport/src/services/user/user.test.ts @@ -93,6 +93,13 @@ test('undefined values in context response gives proper default values', async ( create: false, remove: false, }, + lock: { + list: false, + read: false, + edit: false, + create: false, + remove: false, + }, recordedSessions: { list: false, read: false, diff --git a/web/packages/teleport/src/stores/storeUserContext.ts b/web/packages/teleport/src/stores/storeUserContext.ts index 5049d38ba37f6..a36a04490dc26 100644 --- a/web/packages/teleport/src/stores/storeUserContext.ts +++ b/web/packages/teleport/src/stores/storeUserContext.ts @@ -88,6 +88,10 @@ export default class StoreUserContext extends Store { return this.state.acl.billing; } + getLockAccess() { + return this.state.acl.lock; + } + getDatabaseServerAccess() { return this.state.acl.dbServers; } diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index f757289082fb3..c4f68470d70e6 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -110,6 +110,8 @@ class TeleportContext implements types.Context { deviceTrust: false, enrollIntegrationsOrPlugins: false, enrollIntegrations: false, + locks: false, + newLocks: false, }; } @@ -138,6 +140,9 @@ class TeleportContext implements types.Context { userContext.getPluginsAccess().create || userContext.getIntegrationsAccess().create, deviceTrust: userContext.getDeviceTrustAccess().list, + locks: userContext.getLockAccess().list, + newLocks: + userContext.getLockAccess().create && userContext.getLockAccess().edit, }; } } diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index 9c41d0ff02a70..24c08d95e6bea 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -94,4 +94,6 @@ export interface FeatureFlags { enrollIntegrationsOrPlugins: boolean; enrollIntegrations: boolean; deviceTrust: boolean; + locks: boolean; + newLocks: boolean; }