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 0000000000000..98d8f85c2c766 Binary files /dev/null and b/web/packages/teleport/src/LocksV2/NewLock/LockCheckout/shield-check.png differ diff --git a/web/packages/teleport/src/LocksV2/NewLock/NewLock.tsx b/web/packages/teleport/src/LocksV2/NewLock/NewLock.tsx new file mode 100644 index 0000000000000..d1c37afcb00d9 --- /dev/null +++ b/web/packages/teleport/src/LocksV2/NewLock/NewLock.tsx @@ -0,0 +1,291 @@ +/** + * 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 { Prompt } from 'react-router'; +import { Link } from 'react-router-dom'; +import { Transition } from 'react-transition-group'; +import { Box, Flex, ButtonSecondary, Text, ButtonPrimary } from 'design'; +import Select from 'shared/components/Select'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import { ArrowBack } from 'design/Icon'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout'; +import ErrorMessage from 'teleport/components/AgentErrorMessage'; +import cfg from 'teleport/config'; + +import { LockCheckout } from './LockCheckout/LockCheckout'; +import { + SimpleList, + SimpleListOpts, +} from './ResourceList/SimpleList/SimpleList'; +import { ServerSideSupportedList } from './ResourceList/ServerSideSupportedList/ServerSideSupportedList'; +import { Logins } from './ResourceList/Logins'; +import { + HybridList, + HybridListOpts, +} from './ResourceList/HybridList/HybridList'; +import { + CommonListProps, + LockResourceMap, + LockResourceOption, + getEmptyResourceMap, + baseResourceKindOpts, + LockResource, +} from './common'; + +const PAGE_SIZE = 10; + +export type Props = { + customResourceKindOpts?: LockResourceOption[]; + simpleListOpts?: SimpleListOpts; + hybridListOpts?: HybridListOpts; +}; + +export default function NewLock() { + return NewLockView({}); +} + +export function NewLockView(props: Props) { + const { attempt, setAttempt } = useAttempt('processing'); + const [showCheckout, setShowCheckout] = useState(false); + const [selectedResourceOpt, setSelectedResourceOpt] = useState( + props.customResourceKindOpts?.length + ? props.customResourceKindOpts[0] + : baseResourceKindOpts[0] + ); + + const [selectedResources, setSelectedResources] = useState( + 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; }