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')}
- >
-
-
-
- {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
-
-
-
-
-
-
-
-
-
- theme.colors.spotBackground[0]};
- `}
- >
-
- Lock targets added ({selectedLockTargets.length})
-
-
- {selectedLockTargets.length > 0 && (
-
- Clear Selections
-
- )}
- setCreatePanelPosition('open')}
- disabled={disabledSubmit}
- >
- Proceed to lock
-
-
-
-
- );
-}
-
-function TargetList({
- data,
- selectedTarget,
- selectedLockTargets,
- onAdd,
-}: TargetListProps) {
- if (!data) data = [];
-
- if (selectedTarget === 'device') {
- return Listing Devices not implemented.;
- }
-
- if (selectedTarget === 'login') {
- return Unable to list logins, use quick add box.;
- }
-
- const columns: TableColumn[] = data.length
- ? Object.keys(data[0])
- .filter(k => k !== 'targetValue') // don't show targetValue in the table
- .map(c => {
- const col: TableColumn = {
- key: c,
- headerText: c === 'lastUsed' ? 'Last Used' : c,
- isSortable: true,
- };
- if (c === 'labels') {
- col.render = target => {
- const labels = target.labels || [];
- return (
- `${l.name}: ${l.value}`)} />
- );
- };
- }
- return col;
- })
- : [];
-
- if (columns.length) {
- columns.push({
- altKey: 'add-btn',
- render: ({ targetValue }) => (
-
-
- target.type === selectedTarget && target.name === targetValue
- )}
- >
- + Add
-
- |
- ),
- });
- }
- return (
-
- );
-}
-
-function QuickAdd({
- selectedTarget,
- selectedLockTargets,
- onAdd,
-}: {
- selectedTarget: string;
- selectedLockTargets: SelectedLockTarget[];
- onAdd: OnAdd;
-}) {
- const [inputValue, setInputValue] = useState('');
- return (
-
- 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 (
+
+ );
+}
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' && (
+
+ )}
+
+
+ {content}
+
+
+ {transitionState => (
+ setShowCheckout(false)}
+ toggleResource={toggleSelectResource}
+ transitionState={transitionState}
+ reset={clearSelectedResources}
+ selectedResourceKind={selectedResourceKind}
+ batchDeleteResources={batchDeleteResources}
+ />
+ )}
+
+ {/* This is a react-router provided prompt when it detects route change.
+ * Prompts user when user navigates away from route, to help avoid losign work.
+ */}
+ 0}
+ message={() => {
+ return `${numAddedResources} resource(s) selected for locking will be cleared if you leave this page. Are you sure you want to continue?`;
+ }}
+ />
+
+
+ );
+}
+
+function CheckoutFooter({
+ numAddedResources,
+ clearSelectedResources,
+ setShowCheckout,
+ isProcessing,
+}: {
+ isProcessing: boolean;
+ numAddedResources: number;
+ clearSelectedResources(): void;
+ setShowCheckout(show: boolean): void;
+}) {
+ return (
+ theme.colors.spotBackground[0]};
+ `}
+ >
+ Targets Added ({numAddedResources})
+
+ {numAddedResources > 0 && (
+ clearSelectedResources()}
+ disabled={isProcessing}
+ >
+ Clear Selections
+
+ )}
+ setShowCheckout(true)}
+ disabled={!numAddedResources || isProcessing}
+ >
+ Proceed to Lock
+
+
+
+ );
+}
+
+function getNumSelectedResources(resourceMap: LockResourceMap) {
+ const kinds = Object.keys(resourceMap);
+ let count = 0;
+
+ kinds.forEach(kind => (count += Object.keys(resourceMap[kind]).length));
+
+ return count;
+}
diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/HybridList/HybridList.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/HybridList/HybridList.tsx
new file mode 100644
index 0000000000000..45020d39ef400
--- /dev/null
+++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/HybridList/HybridList.tsx
@@ -0,0 +1,126 @@
+/**
+ * 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 { FetchStatus } from 'design/DataTable/types';
+
+import { UrlResourcesParams } from 'teleport/config';
+
+import { TableWrapper, HybridListProps } from '../common';
+import { CommonListProps, LockResourceKind } from '../../common';
+
+export type HybridListOpts = {
+ getFetchFn(selectedResourceKind: LockResourceKind): (p: any) => Promise;
+ getTable(
+ selectedResourceKind: LockResourceKind,
+ resources: any[],
+ listProps: HybridListProps
+ ): React.ReactElement;
+};
+
+export function HybridList(props: CommonListProps & { opts: HybridListOpts }) {
+ const [tableData, setTableData] = useState(emptyTableData);
+
+ function fetchNextPage() {
+ fetch({ ...tableData });
+ }
+
+ function fetch(data: TableData) {
+ const fetchFn = data.fetchFn;
+ setTableData({ ...data, fetchStatus: 'loading' });
+ props.setAttempt({ status: 'processing' });
+
+ fetchFn({ startKey: data.startKey, limit: props.pageSize })
+ .then(resp => {
+ props.setAttempt({ status: 'success' });
+ setTableData({
+ fetchFn,
+ startKey: resp.startKey,
+ fetchStatus: resp.startKey ? '' : 'disabled',
+ // concat each page fetch.
+ resources: [...data.resources, ...resp.items],
+ });
+ })
+ .catch((err: Error) => {
+ props.setAttempt({ status: 'failed', statusText: err.message });
+ setTableData({ ...data, fetchStatus: '' }); // fallback to previous data
+ });
+ }
+
+ // Load the correct function to use for init fetch and
+ // for next pages.
+ useEffect(() => {
+ let fetchFn;
+ switch (props.selectedResourceKind) {
+ default:
+ fetchFn = props.opts?.getFetchFn(props.selectedResourceKind);
+ if (!fetchFn) {
+ console.error(
+ `[HybridList.tsx] fetchFn not defined for resource kind ${props.selectedResourceKind}`
+ );
+ return; // don't do anything on error
+ }
+ }
+
+ // Reset table per selected resource change.
+ fetch({ ...emptyTableData, fetchFn });
+ }, [props.selectedResourceKind]);
+
+ const table = useMemo(() => {
+ const listProps: HybridListProps = {
+ pageSize: props.pageSize,
+ selectedResources: props.selectedResources,
+ toggleSelectResource: props.toggleSelectResource,
+ fetchNextPage,
+ fetchStatus: tableData.fetchStatus,
+ };
+ switch (props.selectedResourceKind) {
+ default:
+ const table = props.opts?.getTable(
+ props.selectedResourceKind,
+ tableData.resources,
+ listProps
+ );
+ if (table) {
+ return table;
+ }
+ console.error(
+ `[HybridList.tsx] table not defined for resource kind ${props.selectedResourceKind}`
+ );
+ }
+ }, [props.attempt, tableData, props.selectedResources]);
+
+ return (
+
+ {table}
+
+ );
+}
+
+const emptyTableData: TableData = {
+ resources: [],
+ fetchStatus: 'loading',
+ startKey: '',
+};
+
+type TableData = {
+ resources: any[];
+ fetchStatus: FetchStatus;
+ startKey: string;
+ fetchFn?(params?: UrlResourcesParams): Promise;
+};
diff --git a/web/packages/teleport/src/LocksV2/NewLock/ResourceList/Logins.tsx b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/Logins.tsx
new file mode 100644
index 0000000000000..83f23c7dfa588
--- /dev/null
+++ b/web/packages/teleport/src/LocksV2/NewLock/ResourceList/Logins.tsx
@@ -0,0 +1,96 @@
+/**
+ * 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 Table from 'design/DataTable';
+import { Box, ButtonSecondary, Flex, Input, Text } from 'design';
+
+import { renderActionCell, LoginsProps } from './common';
+
+export function Logins(props: LoginsProps) {
+ const [loginInput, setLoginInput] = useState('');
+ const [addedLogins, setAddedLogins] = useState<{ login: string }[]>(() => {
+ const loginMap = props.selectedResources.login;
+ return Object.keys(loginMap).map(login => ({ login }));
+ });
+
+ function addLogin(e: React.MouseEvent) {
+ e.preventDefault(); // from form submit event
+
+ props.toggleSelectResource({ kind: 'login', targetValue: loginInput });
+ setLoginInput('');
+
+ setAddedLogins([...addedLogins, { login: loginInput }]);
+ }
+
+ return (
+
+
+ Listing logins are not supported. Use the input box below to manually
+ define a login to lock.
+ Double check the spelling before adding.
+
+
+ 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;
}