diff --git a/lib/web/users.go b/lib/web/users.go
index cada9a3996c97..8594b501b0ac6 100644
--- a/lib/web/users.go
+++ b/lib/web/users.go
@@ -104,7 +104,16 @@ func createUser(r *http.Request, m userAPIGetter, createdBy string) (*ui.User, e
user.SetRoles(req.Roles)
- updateUserTraits(req, user)
+ // checkAndSetDefaults makes sure either TraitsPreset
+ // or AllTraits field to be populated. Since empty
+ // AllTraits is also used to delete all user traits,
+ // we explicitly check if TraitsPreset is empty so
+ // to prevent traits deletion.
+ if req.TraitsPreset == nil {
+ user.SetTraits(req.AllTraits)
+ } else {
+ updateUserTraitsPreset(req, user)
+ }
user.SetCreatedBy(types.CreatedBy{
User: types.UserRef{Name: createdBy},
@@ -119,30 +128,30 @@ func createUser(r *http.Request, m userAPIGetter, createdBy string) (*ui.User, e
return ui.NewUser(created)
}
-// updateUserTraits receives a saveUserRequest and updates the user traits accordingly
-// It only updates the traits that have a non-nil value in saveUserRequest
-// This allows the partial update of the properties
-func updateUserTraits(req *saveUserRequest, user types.User) {
- if req.Traits.Logins != nil {
- user.SetLogins(*req.Traits.Logins)
+// updateUserTraitsPreset receives a saveUserRequest and updates the user traits
+// accordingly. It only updates the traits that have a non-nil value in
+// saveUserRequest. This allows the partial update of the properties
+func updateUserTraitsPreset(req *saveUserRequest, user types.User) {
+ if req.TraitsPreset.Logins != nil {
+ user.SetLogins(*req.TraitsPreset.Logins)
}
- if req.Traits.DatabaseUsers != nil {
- user.SetDatabaseUsers(*req.Traits.DatabaseUsers)
+ if req.TraitsPreset.DatabaseUsers != nil {
+ user.SetDatabaseUsers(*req.TraitsPreset.DatabaseUsers)
}
- if req.Traits.DatabaseNames != nil {
- user.SetDatabaseNames(*req.Traits.DatabaseNames)
+ if req.TraitsPreset.DatabaseNames != nil {
+ user.SetDatabaseNames(*req.TraitsPreset.DatabaseNames)
}
- if req.Traits.KubeUsers != nil {
- user.SetKubeUsers(*req.Traits.KubeUsers)
+ if req.TraitsPreset.KubeUsers != nil {
+ user.SetKubeUsers(*req.TraitsPreset.KubeUsers)
}
- if req.Traits.KubeGroups != nil {
- user.SetKubeGroups(*req.Traits.KubeGroups)
+ if req.TraitsPreset.KubeGroups != nil {
+ user.SetKubeGroups(*req.TraitsPreset.KubeGroups)
}
- if req.Traits.WindowsLogins != nil {
- user.SetWindowsLogins(*req.Traits.WindowsLogins)
+ if req.TraitsPreset.WindowsLogins != nil {
+ user.SetWindowsLogins(*req.TraitsPreset.WindowsLogins)
}
- if req.Traits.AWSRoleARNs != nil {
- user.SetAWSRoleARNs(*req.Traits.AWSRoleARNs)
+ if req.TraitsPreset.AWSRoleARNs != nil {
+ user.SetAWSRoleARNs(*req.TraitsPreset.AWSRoleARNs)
}
}
@@ -169,7 +178,16 @@ func updateUser(r *http.Request, m userAPIGetter) (*ui.User, error) {
user.SetRoles(req.Roles)
- updateUserTraits(req, user)
+ // checkAndSetDefaults makes sure either TraitsPreset
+ // or AllTraits field to be populated. Since empty
+ // AllTraits is also used to delete all user traits,
+ // we explicitly check if TraitsPreset is empty so
+ // to prevent traits deletion.
+ if req.TraitsPreset == nil {
+ user.SetTraits(req.AllTraits)
+ } else {
+ updateUserTraitsPreset(req, user)
+ }
updated, err := m.UpdateUser(r.Context(), user)
if err != nil {
@@ -287,7 +305,8 @@ type userAPIGetter interface {
DeleteUser(ctx context.Context, user string) error
}
-type userTraits struct {
+// traitsPreset are user traits that are pre-defined in Teleport
+type traitsPreset struct {
Logins *[]string `json:"logins,omitempty"`
DatabaseUsers *[]string `json:"databaseUsers,omitempty"`
DatabaseNames *[]string `json:"databaseNames,omitempty"`
@@ -303,11 +322,21 @@ type userTraits struct {
// They are optional and respect the following logic:
// - if the value is nil, we ignore it
// - if the value is an empty array we remove every element from the trait
-// - otherwise, we replace the list for that trait
+// - otherwise, we replace the list for that trait.
+// Use TraitsPreset to selectively update traits.
+// Use AllTraits to fully replace existing traits.
type saveUserRequest struct {
- Name string `json:"name"`
- Roles []string `json:"roles"`
- Traits userTraits `json:"traits"`
+ // Name is username.
+ Name string `json:"name"`
+ // Roles is slice of user roles assigned to user.
+ Roles []string `json:"roles"`
+ // TraitsPreset holds traits that are pre-defined in Teleport.
+ // Clients may use TraitsPreset to selectively update user traits.
+ TraitsPreset *traitsPreset `json:"traits"`
+ // AllTraits may hold all the user traits, including traits key defined
+ // in TraitsPreset and/or new trait key values defined by Teleport admin.
+ // AllTraits should be used to fully replace and update user traits.
+ AllTraits map[string][]string `json:"allTraits"`
}
func (r *saveUserRequest) checkAndSetDefaults() error {
@@ -317,5 +346,8 @@ func (r *saveUserRequest) checkAndSetDefaults() error {
if len(r.Roles) == 0 {
return trace.BadParameter("missing roles")
}
+ if len(r.AllTraits) != 0 && r.TraitsPreset != nil {
+ return trace.BadParameter("either traits or allTraits must be provided")
+ }
return nil
}
diff --git a/lib/web/users_test.go b/lib/web/users_test.go
index 5cd9beb80f985..9d8d729f0544e 100644
--- a/lib/web/users_test.go
+++ b/lib/web/users_test.go
@@ -34,40 +34,100 @@ import (
)
func TestRequestParameters(t *testing.T) {
- r := saveUserRequest{
- Name: "",
- Roles: nil,
- Traits: userTraits{},
- }
- require.True(t, trace.IsBadParameter(r.checkAndSetDefaults()))
-
- r = saveUserRequest{
- Name: "",
- Roles: []string{"testrole"},
- Traits: userTraits{},
+ tests := []struct {
+ name string
+ username string
+ role []string
+ traitsPreset *traitsPreset
+ allTraits map[string][]string
+ errAssertion require.ErrorAssertionFunc
+ }{
+ {
+ name: "empty request",
+ username: "",
+ role: nil,
+ traitsPreset: nil,
+ allTraits: nil,
+ errAssertion: func(t require.TestingT, err error, i ...interface{}) {
+ require.ErrorIs(t, err, trace.BadParameter("missing user name"))
+ },
+ },
+ {
+ name: "empty name",
+ username: "",
+ role: []string{"testrole"},
+ traitsPreset: nil,
+ allTraits: nil,
+ errAssertion: func(t require.TestingT, err error, i ...interface{}) {
+ require.ErrorIs(t, err, trace.BadParameter("missing user name"))
+ },
+ },
+ {
+ name: "empty role",
+ username: "testuser",
+ role: nil,
+ traitsPreset: nil,
+ allTraits: nil,
+ errAssertion: func(t require.TestingT, err error, i ...interface{}) {
+ require.ErrorIs(t, err, trace.BadParameter("missing roles"))
+ },
+ },
+ {
+ name: "both traitsPreset and allTraits",
+ username: "testuser",
+ role: []string{"testrole"},
+ traitsPreset: &traitsPreset{Logins: &[]string{"root"}},
+ allTraits: map[string][]string{"logins": {"root"}},
+ errAssertion: func(t require.TestingT, err error, i ...interface{}) {
+ require.ErrorIs(t, err, trace.BadParameter("either traits or allTraits must be provided"))
+ },
+ },
+ {
+ name: "user without traits",
+ username: "testuser",
+ role: []string{"testrole"},
+ traitsPreset: nil,
+ allTraits: nil,
+ errAssertion: require.NoError,
+ },
+ {
+ name: "user with traitsPreset",
+ username: "testuser",
+ role: []string{"testrole"},
+ traitsPreset: &traitsPreset{Logins: &[]string{"root"}},
+ allTraits: map[string][]string{},
+ errAssertion: require.NoError,
+ },
+ {
+ name: "user with allTraits",
+ username: "testuser",
+ role: []string{"testrole"},
+ traitsPreset: nil,
+ allTraits: map[string][]string{"logins": {"root"}},
+ errAssertion: require.NoError,
+ },
}
- require.True(t, trace.IsBadParameter(r.checkAndSetDefaults()))
- r = saveUserRequest{
- Name: "username",
- Roles: nil,
- Traits: userTraits{},
- }
- require.True(t, trace.IsBadParameter(r.checkAndSetDefaults()))
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ r := saveUserRequest{
+ Name: test.username,
+ Roles: test.role,
+ TraitsPreset: test.traitsPreset,
+ AllTraits: test.allTraits,
+ }
- r = saveUserRequest{
- Name: "username",
- Roles: []string{"testrole"},
- Traits: userTraits{},
+ err := r.checkAndSetDefaults()
+ test.errAssertion(t, err)
+ })
}
- require.NoError(t, r.checkAndSetDefaults())
}
func TestCRUDs(t *testing.T) {
u := saveUserRequest{
- Name: "testname",
- Roles: []string{"testrole"},
- Traits: userTraits{},
+ Name: "testname",
+ Roles: []string{"testrole"},
+ TraitsPreset: nil,
}
m := &mockedUserAPIGetter{}
@@ -120,7 +180,7 @@ func TestCRUDs(t *testing.T) {
require.NoError(t, err)
}
-func TestUpdateUser_setTraits(t *testing.T) {
+func TestUpdateUser_updateUserTraitsPreset(t *testing.T) {
defaultRoles := []string{"role1"}
defaultLogins := []string{"login1"}
tests := []struct {
@@ -131,9 +191,9 @@ func TestUpdateUser_setTraits(t *testing.T) {
{
name: "Logins",
updateReq: saveUserRequest{
- Name: "setlogins",
- Roles: defaultRoles,
- Traits: userTraits{Logins: &[]string{"login1", "login2"}},
+ Name: "setlogins",
+ Roles: defaultRoles,
+ TraitsPreset: &traitsPreset{Logins: &[]string{"login1", "login2"}},
},
expectedTraits: map[string][]string{
constants.TraitLogins: {"login1", "login2"},
@@ -144,7 +204,7 @@ func TestUpdateUser_setTraits(t *testing.T) {
updateReq: saveUserRequest{
Name: "setdb",
Roles: defaultRoles,
- Traits: userTraits{
+ TraitsPreset: &traitsPreset{
Logins: &defaultLogins,
DatabaseUsers: &[]string{"dbuser1", "dbuser2"},
DatabaseNames: &[]string{"dbname1", "dbname2"},
@@ -161,7 +221,7 @@ func TestUpdateUser_setTraits(t *testing.T) {
updateReq: saveUserRequest{
Name: "setkube",
Roles: defaultRoles,
- Traits: userTraits{
+ TraitsPreset: &traitsPreset{
Logins: &defaultLogins,
KubeUsers: &[]string{"kubeuser1", "kubeuser2"},
KubeGroups: &[]string{"kubegroup1", "kubegroup2"},
@@ -178,7 +238,7 @@ func TestUpdateUser_setTraits(t *testing.T) {
updateReq: saveUserRequest{
Name: "setwindowslogins",
Roles: defaultRoles,
- Traits: userTraits{
+ TraitsPreset: &traitsPreset{
Logins: &defaultLogins,
WindowsLogins: &[]string{"login1", "login2"},
},
@@ -193,7 +253,7 @@ func TestUpdateUser_setTraits(t *testing.T) {
updateReq: saveUserRequest{
Name: "setawsrolearns",
Roles: defaultRoles,
- Traits: userTraits{
+ TraitsPreset: &traitsPreset{
Logins: &defaultLogins,
AWSRoleARNs: &[]string{"arn1", "arn2"},
},
@@ -206,9 +266,9 @@ func TestUpdateUser_setTraits(t *testing.T) {
{
name: "Deduplicates",
updateReq: saveUserRequest{
- Name: "deduplicates",
- Roles: defaultRoles,
- Traits: userTraits{Logins: &[]string{"login1", "login2", "login1"}},
+ Name: "deduplicates",
+ Roles: defaultRoles,
+ TraitsPreset: &traitsPreset{Logins: &[]string{"login1", "login2", "login1"}},
},
expectedTraits: map[string][]string{
constants.TraitLogins: {"login1", "login2"},
@@ -217,9 +277,9 @@ func TestUpdateUser_setTraits(t *testing.T) {
{
name: "RemovesAll",
updateReq: saveUserRequest{
- Name: "removesall",
- Roles: defaultRoles,
- Traits: userTraits{Logins: &[]string{}},
+ Name: "removesall",
+ Roles: defaultRoles,
+ TraitsPreset: &traitsPreset{Logins: &[]string{}},
},
expectedTraits: map[string][]string{
constants.TraitLogins: {},
@@ -268,6 +328,47 @@ func TestUpdateUser_setTraits(t *testing.T) {
}
}
+func TestUpdateUser_setTraitsWithAllTraits(t *testing.T) {
+ defaultRoles := []string{"role1"}
+
+ // create user
+ user, err := types.NewUser("alice")
+ require.NoError(t, err)
+ user.SetRoles(defaultRoles)
+
+ m := &mockedUserAPIGetter{}
+ m.mockGetUser = func(ctx context.Context, name string, withSecrets bool) (types.User, error) {
+ return user, nil
+ }
+ m.mockUpdateUser = func(ctx context.Context, user types.User) (types.User, error) {
+ return user, nil
+ }
+
+ // update user with AllTraits
+ allTraitsWithValue := saveUserRequest{
+ Name: "setlogins",
+ Roles: defaultRoles,
+ AllTraits: map[string][]string{"logins": {"root", "admin"}},
+ }
+ _, err = updateUser(newRequest(t, allTraitsWithValue), m)
+ require.NoError(t, err)
+ require.Equal(t, allTraitsWithValue.AllTraits, user.GetTraits())
+
+ // verify other fields dont't change
+ require.ElementsMatch(t, user.GetRoles(), defaultRoles)
+
+ // update user with empty AllTraits
+ emptyAllTraits := saveUserRequest{
+ Name: "setlogins",
+ Roles: defaultRoles,
+ AllTraits: map[string][]string{},
+ }
+ _, err = updateUser(newRequest(t, emptyAllTraits), m)
+ require.NoError(t, err)
+ // empty AllTraits field should delete existing traits
+ require.Equal(t, emptyAllTraits.AllTraits, user.GetTraits())
+}
+
func TestCRUDErrors(t *testing.T) {
m := &mockedUserAPIGetter{}
m.mockCreateUser = func(ctx context.Context, user types.User) (types.User, error) {
@@ -291,9 +392,9 @@ func TestCRUDErrors(t *testing.T) {
}
u := saveUserRequest{
- Name: "testname",
- Roles: []string{"testrole"},
- Traits: userTraits{Logins: nil},
+ Name: "testname",
+ Roles: []string{"testrole"},
+ TraitsPreset: &traitsPreset{Logins: nil},
}
// update errors
diff --git a/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.test.tsx b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.test.tsx
new file mode 100644
index 0000000000000..0a89e6c79f344
--- /dev/null
+++ b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * Teleport
+ * Copyright (C) 2023 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import { fireEvent, render, screen } from 'design/utils/testing';
+
+import Validation from 'shared/components/Validation';
+
+import { AllUserTraits } from 'teleport/services/user';
+
+import { TraitsEditor, emptyTrait } from './TraitsEditor';
+
+import { traitsToTraitsOption } from './useDialog';
+
+import type { TraitsOption } from './TraitsEditor';
+
+test('Available traits are rendered', async () => {
+ const setConfiguredTraits = jest.fn();
+ const userTraits: AllUserTraits = {
+ logins: ['root', 'ubuntu'],
+ db_roles: ['dbadmin', 'postgres'],
+ db_names: ['postgres', 'aurora'],
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('User Traits')).toBeInTheDocument();
+ expect(screen.getAllByTestId('trait-key')).toHaveLength(3);
+ expect(screen.getAllByTestId('trait-value')).toHaveLength(3);
+});
+
+test('Add and remove Trait', async () => {
+ const configuredTraits: TraitsOption[] = [];
+ const setConfiguredTraits = jest.fn();
+
+ const { rerender } = render(
+
+
+
+ );
+ expect(screen.queryAllByTestId('trait-key')).toHaveLength(0);
+
+ const addButtonEl = screen.getByRole('button', { name: /Add user trait/i });
+ expect(addButtonEl).toBeInTheDocument();
+ fireEvent.click(addButtonEl);
+
+ expect(setConfiguredTraits).toHaveBeenLastCalledWith([emptyTrait]);
+
+ const singleTrait = { logins: ['root', 'ubuntu'] };
+ rerender(
+
+
+
+ );
+ fireEvent.click(screen.getByTitle('Remove Trait'));
+ expect(setConfiguredTraits).toHaveBeenCalledWith([]);
+});
diff --git a/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.tsx b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.tsx
new file mode 100644
index 0000000000000..b4a8bab0c8966
--- /dev/null
+++ b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.tsx
@@ -0,0 +1,259 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import React, { Dispatch, SetStateAction } from 'react';
+import { ButtonBorder, Box, Flex, Text, ButtonIcon } from 'design';
+import { Add, Trash } from 'design/Icon';
+import { FieldSelectCreatable } from 'shared/components/FieldSelect';
+import { Option } from 'shared/components/Select';
+import { requiredField, requiredAll } from 'shared/components/Validation/rules';
+import { Attempt } from 'shared/hooks/useAttemptNext';
+
+/**
+ * traitsPreset is a list of system defined traits in Teleport.
+ * The list is used to populate traits key option.
+ */
+const traitsPreset = [
+ 'aws_role_arns',
+ 'azure_identities',
+ 'db_names',
+ 'db_roles',
+ 'db_users',
+ 'gcp_service_accounts',
+ 'host_user_gid',
+ 'host_user_uid',
+ 'kubernetes_groups',
+ 'kubernetes_users',
+ 'logins',
+ 'windows_logins',
+];
+
+/**
+ * TraitsEditor supports add, edit or remove traits functionality.
+ * @param attempt attempt is Attempt status.
+ * @param configuredTraits holds traits configured for user in current editor.
+ * @param setConfiguredTraits sets user traits in current editor.
+ */
+export function TraitsEditor({
+ attempt,
+ configuredTraits,
+ setConfiguredTraits,
+}: TraitEditorProps) {
+ function handleInputChange(i: InputOption | InputOptionArray) {
+ const newTraits = [...configuredTraits];
+ if (i.labelField === 'traitValues') {
+ const traitValue: Option[] = i.option;
+ if (traitValue) {
+ if (traitValue[traitValue.length - 1].value.trim() === '') {
+ return;
+ }
+ traitValue[traitValue.length - 1].label =
+ traitValue[traitValue.length - 1].label.trim();
+ traitValue[traitValue.length - 1].value =
+ traitValue[traitValue.length - 1].value.trim();
+ }
+ newTraits[i.index] = {
+ ...newTraits[i.index],
+ [i.labelField]: traitValue ?? [],
+ };
+ setConfiguredTraits(newTraits);
+ } else {
+ const traitName: Option = i.option;
+ traitName.label = traitName.label.trim();
+ traitName.value = traitName.value.trim();
+ newTraits[i.index] = {
+ ...newTraits[i.index],
+ [i.labelField]: traitName,
+ };
+ setConfiguredTraits(newTraits);
+ }
+ }
+
+ function addNewTraitPair() {
+ setConfiguredTraits([...configuredTraits, emptyTrait]);
+ }
+
+ function removeTraitPair(index: number) {
+ const newTraits = [...configuredTraits];
+ newTraits.splice(index, 1);
+ setConfiguredTraits(newTraits);
+ }
+
+ const addLabelText =
+ configuredTraits.length > 0 ? 'Add another user trait' : 'Add user trait';
+
+ return (
+
+ User Traits
+
+ {configuredTraits.map(({ traitKey, traitValues }, index) => {
+ return (
+
+
+
+ ({
+ value: r,
+ label: r,
+ }))}
+ placeholder="Type a trait name and press enter"
+ autoFocus
+ isSearchable
+ value={traitKey}
+ label="Key"
+ rule={requiredAll(
+ requiredField('Trait key is required'),
+ requireNoDuplicateTraits(configuredTraits)
+ )}
+ onChange={e => {
+ handleInputChange({
+ option: e as Option,
+ labelField: 'traitKey',
+ index: index,
+ });
+ }}
+ createOptionPosition="last"
+ isDisabled={attempt.status === 'processing'}
+ />
+
+
+ props.theme.colors.levels.surface};
+ `}
+ placeholder="Type a trait value and press enter"
+ defaultValue={traitValues.map(r => ({
+ value: r,
+ label: r,
+ }))}
+ label="Value"
+ isMulti
+ isSearchable
+ isClearable={false}
+ value={traitValues}
+ rule={requiredField('Trait value cannot be empty')}
+ onChange={e => {
+ handleInputChange({
+ option: e as Option[],
+ labelField: 'traitValues',
+ index: index,
+ });
+ }}
+ formatCreateLabel={(i: string) =>
+ 'Trait value: ' + `"${i}"`
+ }
+ isDisabled={attempt.status === 'processing'}
+ />
+
+ removeTraitPair(index)}
+ css={`
+ &:disabled {
+ opacity: 0.65;
+ pointer-events: none;
+ }
+ `}
+ disabled={attempt.status === 'processing'}
+ >
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ {addLabelText}
+
+
+
+ );
+}
+
+type InputOption = {
+ labelField: 'traitKey';
+ option: Option;
+ index: number;
+};
+
+type InputOptionArray = {
+ labelField: 'traitValues';
+ option: Option[];
+ index: number;
+};
+
+const requireNoDuplicateTraits =
+ (configuredTraits: TraitsOption[]) => (enteredTrait: Option) => () => {
+ const traitKey = configuredTraits.map(trait =>
+ trait.traitKey.value.toLowerCase()
+ );
+ let occurance = 0;
+ traitKey.forEach(key => {
+ if (key === enteredTrait.value.toLowerCase()) {
+ occurance++;
+ }
+ });
+ if (occurance > 1) {
+ return { valid: false, message: 'Trait key should be unique for a user' };
+ }
+ return { valid: true };
+ };
+
+export const emptyTrait = {
+ traitKey: { value: '', label: 'Type a trait name and press enter' },
+ traitValues: [],
+};
+
+export type TraitsOption = { traitKey: Option; traitValues: Option[] };
+
+export type TraitEditorProps = {
+ setConfiguredTraits: Dispatch>;
+ configuredTraits: TraitsOption[];
+ attempt: Attempt;
+};
diff --git a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx
index 67a316c4e2bb8..e47b534c07cae 100644
--- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx
+++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx
@@ -16,10 +16,14 @@
* along with this program. If not, see .
*/
-import React from 'react';
+import React, { useState } from 'react';
+
+import { AllUserTraits } from 'teleport/services/user';
import { UserAddEdit } from './UserAddEdit';
+import type { TraitsOption } from './TraitsEditor';
+
export default {
title: 'Teleport/Users/UserAddEdit',
};
@@ -38,7 +42,15 @@ export const Create = () => {
};
export const Edit = () => {
- return ;
+ const [configuredTraits, setConfiguredTraits] = useState([]);
+ return (
+
+ );
};
export const Processing = () => {
@@ -72,4 +84,12 @@ const props = {
expires: new Date('2050-12-20T17:28:20.93Z'),
username: 'Lester',
},
+ allTraits: { ['logins']: ['root', 'ubuntu'] } as AllUserTraits,
+ configuredTraits: [
+ {
+ traitKey: { value: 'logins', label: 'logins' },
+ traitValues: [{ value: 'root', label: 'root' }],
+ },
+ ] as TraitsOption[],
+ setConfiguredTraits: () => null,
};
diff --git a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx
index cefb4c64a3528..b2a1504a8fe7e 100644
--- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx
+++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx
@@ -17,7 +17,7 @@
*/
import React from 'react';
-import { ButtonPrimary, ButtonSecondary, Alert } from 'design';
+import { ButtonPrimary, ButtonSecondary, Alert, Box } from 'design';
import Dialog, {
DialogHeader,
DialogTitle,
@@ -33,6 +33,8 @@ import { requiredField } from 'shared/components/Validation/rules';
import UserTokenLink from './../UserTokenLink';
import useDialog, { Props } from './useDialog';
+import { TraitsEditor } from './TraitsEditor';
+
export default function Container(props: Props) {
const dialog = useDialog(props);
return ;
@@ -44,12 +46,14 @@ export function UserAddEdit(props: ReturnType) {
onChangeRoles,
onClose,
fetchRoles,
+ setConfiguredTraits,
attempt,
name,
selectedRoles,
onSave,
isNew,
token,
+ configuredTraits,
} = props;
if (attempt.status === 'success' && isNew) {
@@ -69,9 +73,9 @@ export function UserAddEdit(props: ReturnType) {
{({ validator }) => (