From 9ae4878e84b39be5c89d6787e9dc49c9f8fa8109 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Thu, 19 Oct 2023 12:41:04 -0600 Subject: [PATCH 1/2] [13] Supporting changes for Cloud email invites (#32439) Backport of #32439 for branch/v13 --- * Add WIP implementation of Teleport email invites This adds a WIP impl of Teleport email invites. Requires a compatible Enterprise build and Cloud API. * Bump e ref and add new validation rule * Various improvements to enable Cloud email invites * Add description to UI role resources * Expose various new react-select options * Add new FieldSelectCreatable * Add some typing for validation rules * Tweak invite button for Cloud to use email UI instead of showing both buttons * Partial implementation for onboarding invites * Add support for Cloud collaborator invites during onboarding This adds various changes to enable showing the invite collaborators form during initial user onboarding. * Adds a `?initial` URL query parameter for the UI to signify the first user; Cloud will append this to invite appropriate invite links. * Added a new ratelimited public endpoint to return a list of preset roles. This just exposes static data available otherwise available in Git and that could be obtained from the public Teleport version shown in ping responses already. * Update e ref for the invite-collaborators branch * Honor the `inputId` parameter if set * bump e ref * Improve typing for `requiredEmailLike` to add a error category The `kind` field can allow the UI to group errors together if several invalid emails are entered. * bump e ref * Destructure the InviteCollaborators component sanely * Set `setDisplayInviteCollaborators` to `null` instead of `false` * Split `FieldSelectCreatable` into its own file * Fix lint * add story for SelectCreatable * Add tests for `requiredEmailLike` * Rename `initial` flag to `invite` Renaming the flag will hopefully clarify the intent. * Add tests for invite collaborators feedback and users rendering * Add rendering test for the invite collaborators card * Clean up lints * Rename types.tsx -> shared.tsx * Relocate invite constant to `Welcome/const.ts` * Split `SelectCreatable` into its own story * Clarify SelectCreatable story * Simplify story; fix lint * Fix type checker failure * Rename `preset-roles` endpoint to `presetroles` to follow API conventions --- lib/auth/init.go | 12 ++- lib/web/apiserver.go | 1 + lib/web/resources.go | 10 ++ lib/web/ui/resource.go | 12 ++- .../components/FieldSelect/FieldSelect.tsx | 21 ++-- .../FieldSelect/FieldSelectCreatable.tsx | 102 ++++++++++++++++++ .../shared/components/FieldSelect/index.ts | 2 + .../shared/components/FieldSelect/shared.tsx | 23 ++++ .../shared/components/Select/Select.story.tsx | 2 +- .../shared/components/Select/Select.tsx | 25 ++++- .../Select/SelectCreatable.story.tsx | 62 +++++++++++ .../shared/components/Select/index.ts | 4 +- .../shared/components/Select/types.ts | 14 ++- .../components/Validation/rules.test.ts | 17 +++ .../shared/components/Validation/rules.ts | 59 ++++++++-- web/packages/teleport/src/Main/Main.test.tsx | 44 ++++++++ web/packages/teleport/src/Main/Main.tsx | 2 + .../teleport/src/Users/Users.story.tsx | 4 + .../teleport/src/Users/Users.test.tsx | 102 ++++++++++++++++++ web/packages/teleport/src/Users/Users.tsx | 36 +++++-- web/packages/teleport/src/Users/useUsers.ts | 41 ++++++- .../NewCredentials/NewCredentials.test.tsx | 15 +++ .../Welcome/NewCredentials/NewCredentials.tsx | 19 ++++ .../src/Welcome/NewCredentials/types.ts | 13 +++ web/packages/teleport/src/Welcome/Welcome.tsx | 18 +++- web/packages/teleport/src/Welcome/const.ts | 17 +++ web/packages/teleport/src/config.ts | 5 + web/packages/teleport/src/features.tsx | 6 +- .../src/services/localStorage/localStorage.ts | 20 +++- .../src/services/localStorage/types.ts | 8 ++ .../src/services/resources/makeResource.ts | 1 + .../src/services/resources/resource.ts | 6 ++ .../teleport/src/services/resources/types.ts | 1 + 33 files changed, 680 insertions(+), 44 deletions(-) create mode 100644 web/packages/shared/components/FieldSelect/FieldSelectCreatable.tsx create mode 100644 web/packages/shared/components/FieldSelect/shared.tsx create mode 100644 web/packages/shared/components/Select/SelectCreatable.story.tsx create mode 100644 web/packages/teleport/src/Users/Users.test.tsx create mode 100644 web/packages/teleport/src/Welcome/const.ts diff --git a/lib/auth/init.go b/lib/auth/init.go index e1bb227f909cf..b136c330ec1b3 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -737,9 +737,10 @@ type PresetRoleManager interface { UpsertRole(ctx context.Context, role types.Role) error } -// createPresetRoles creates preset role resources -func createPresetRoles(ctx context.Context, rm PresetRoleManager) error { - roles := []types.Role{ +// GetPresetRoles returns a list of all preset roles expected to be available on +// this cluster. +func GetPresetRoles() []types.Role { + return []types.Role{ services.NewPresetGroupAccessRole(), services.NewPresetEditorRole(), services.NewPresetAccessRole(), @@ -751,6 +752,11 @@ func createPresetRoles(ctx context.Context, rm PresetRoleManager) error { services.NewPresetDeviceEnrollRole(), services.NewPresetRequireTrustedDeviceRole(), } +} + +// createPresetRoles creates preset role resources +func createPresetRoles(ctx context.Context, rm PresetRoleManager) error { + roles := GetPresetRoles() g, gctx := errgroup.WithContext(ctx) for _, role := range roles { diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 67e38ea3d9269..64bd93ebe4839 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -715,6 +715,7 @@ func (h *Handler) bindDefaultEndpoints() { h.POST("/webapi/roles", h.WithAuth(h.upsertRoleHandle)) h.PUT("/webapi/roles/:name", h.WithAuth(h.upsertRoleHandle)) h.DELETE("/webapi/roles/:name", h.WithAuth(h.deleteRole)) + h.GET("/webapi/presetroles", h.WithUnauthenticatedHighLimiter(h.getPresetRoles)) h.GET("/webapi/github", h.WithAuth(h.getGithubConnectorsHandle)) h.POST("/webapi/github", h.WithAuth(h.upsertGithubConnectorHandle)) diff --git a/lib/web/resources.go b/lib/web/resources.go index fef9289b0bba7..10bf6fae81816 100644 --- a/lib/web/resources.go +++ b/lib/web/resources.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" kubeproto "github.com/gravitational/teleport/api/gen/proto/go/teleport/kube/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/httplib" @@ -150,6 +151,15 @@ func upsertRole(ctx context.Context, clt resourcesAPIGetter, content, httpMethod return ui.NewResourceItem(role) } +// getPresetRoles returns a list of preset roles expected to be available on +// this server. These are hard-coded for a given Teleport version, so this +// should have the same security implications as the Teleport version exposed +// via the public ping endpoint. +func (h *Handler) getPresetRoles(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { + presets := auth.GetPresetRoles() + return ui.NewRoles(presets) +} + func (h *Handler) getGithubConnectorsHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { clt, err := ctx.GetClient() if err != nil { diff --git a/lib/web/ui/resource.go b/lib/web/ui/resource.go index c8137fc833745..790c746a6d025 100644 --- a/lib/web/ui/resource.go +++ b/lib/web/ui/resource.go @@ -35,6 +35,8 @@ type ResourceItem struct { Kind string `json:"kind"` // Name is a resource name. Name string `json:"name"` + // Description is an optional resource description. + Description string `json:"description,omitempty"` // Content is resource yaml content. Content string `json:"content"` } @@ -48,12 +50,14 @@ func NewResourceItem(resource types.Resource) (*ResourceItem, error) { kind := resource.GetKind() name := resource.GetName() + description := resource.GetMetadata().Description return &ResourceItem{ - ID: fmt.Sprintf("%v:%v", kind, name), - Kind: kind, - Name: name, - Content: string(data[:]), + ID: fmt.Sprintf("%v:%v", kind, name), + Kind: kind, + Name: name, + Description: description, + Content: string(data[:]), }, nil } diff --git a/web/packages/shared/components/FieldSelect/FieldSelect.tsx b/web/packages/shared/components/FieldSelect/FieldSelect.tsx index 373c3222efb88..284b0287bd35e 100644 --- a/web/packages/shared/components/FieldSelect/FieldSelect.tsx +++ b/web/packages/shared/components/FieldSelect/FieldSelect.tsx @@ -20,9 +20,12 @@ import { Box, LabelInput } from 'design'; import { useRule } from 'shared/components/Validation'; -import Select, { Props as SelectProps } from './../Select'; +import Select, { Props as SelectProps } from '../Select'; + +import { LabelTip, defaultRule } from './shared'; export default function FieldSelect({ + components, label, labelTip, value, @@ -35,11 +38,13 @@ export default function FieldSelect({ isMulti, menuPosition, rule = defaultRule, + stylesConfig, isSearchable = false, isSimpleValue = false, autoFocus = false, isDisabled = false, elevated = false, + inputId = 'select', ...styles }: Props) { const { valid, message } = useRule(rule(value)); @@ -48,13 +53,15 @@ export default function FieldSelect({ return ( {label && ( - + {labelText} {labelTip && } )} @@ -59,6 +66,20 @@ export function SelectAsync(props: AsyncProps) { ); } +export function SelectCreatable(props: CreatableProps) { + const { hasError = false, stylesConfig, ...restOfProps } = props; + return ( + + + + ); +} + export const StyledSelect = styled.div` .react-select-container { box-sizing: border-box; diff --git a/web/packages/shared/components/Select/SelectCreatable.story.tsx b/web/packages/shared/components/Select/SelectCreatable.story.tsx new file mode 100644 index 0000000000000..554ab9cc72546 --- /dev/null +++ b/web/packages/shared/components/Select/SelectCreatable.story.tsx @@ -0,0 +1,62 @@ +/* +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 { Flex, Box } from 'design'; + +import { SelectCreatable, Option } from '../Select'; + +export default { + title: 'Shared/SelectCreatable', +}; + +export const Selects = () => { + const [input, setInput] = React.useState(''); + const [selected, setSelected] = React.useState(); + + return ( + // Note that these examples don't provide for great UX. Implementations + // should consider adding an `onKeyDown` handler to split entries on + // keypress (tab, comma, enter, etc) rather than relying on react-select's + // "Create [foo]" dropdown. + + + Multiple input + setInput(v)} + onChange={v => setSelected((v as Option[] | null) || [])} + /> + Note: accept new candidate with Enter or mouse click + + + Single input + setInput(v)} + onChange={v => setSelected((v as Option[] | null) || [])} + /> + + + ); +}; diff --git a/web/packages/shared/components/Select/index.ts b/web/packages/shared/components/Select/index.ts index 7959714f646b4..7145aa08cb0c2 100644 --- a/web/packages/shared/components/Select/index.ts +++ b/web/packages/shared/components/Select/index.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import Select, { SelectAsync, StyledSelect } from './Select'; +import Select, { SelectAsync, SelectCreatable, StyledSelect } from './Select'; import DarkStyledSelect from './DarkStyledSelect'; export * from './types'; export default Select; -export { SelectAsync, DarkStyledSelect, StyledSelect }; +export { SelectAsync, SelectCreatable, DarkStyledSelect, StyledSelect }; diff --git a/web/packages/shared/components/Select/types.ts b/web/packages/shared/components/Select/types.ts index 7eeb879fde4c2..c2c1d8fdc61d8 100644 --- a/web/packages/shared/components/Select/types.ts +++ b/web/packages/shared/components/Select/types.ts @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React, { FocusEvent } from 'react'; + +import { StylesConfig } from 'react-select'; + export type Props = { inputId?: string; hasError?: boolean; @@ -26,7 +30,7 @@ export type Props = { controlShouldRenderValue?: boolean; maxMenuHeight?: number; onChange(e: Option | Option[]): void; - onKeyDown?(e: KeyboardEvent): void; + onKeyDown?(e: KeyboardEvent | React.KeyboardEvent): void; value: null | Option | Option[]; isMulti?: boolean; autoFocus?: boolean; @@ -45,6 +49,7 @@ export type Props = { onInputChange?(value: string, actionMeta: ActionMeta): void; // Whether or not the element is on an elevated platform (such as a dialog). elevated?: boolean; + stylesConfig?: StylesConfig; }; export type AsyncProps = Omit & { @@ -55,6 +60,13 @@ export type AsyncProps = Omit & { noOptionsMessage(): string; }; +/** + * Properties specific to `react-select`'s Creatable widget. + */ +export type CreatableProps = Omit & { + onBlur?(e: FocusEvent): void; +}; + // Option defines the data type for select dropdown list. export type Option = { // value is the actual value used inlieu of label. diff --git a/web/packages/shared/components/Validation/rules.test.ts b/web/packages/shared/components/Validation/rules.test.ts index 43995b5b003b3..def55af421b04 100644 --- a/web/packages/shared/components/Validation/rules.test.ts +++ b/web/packages/shared/components/Validation/rules.test.ts @@ -20,6 +20,7 @@ import { requiredConfirmedPassword, requiredField, requiredRoleArn, + requiredEmailLike, } from './rules'; describe('requiredField', () => { @@ -99,3 +100,19 @@ describe('requiredConfirmedPassword', () => { } ); }); + +describe('requiredEmailLike', () => { + test.each` + email | expected + ${''} | ${{ valid: false, kind: 'empty' }} + ${'alice'} | ${{ valid: false, kind: 'invalid' }} + ${'alice@'} | ${{ valid: false, kind: 'invalid' }} + ${'@example.com'} | ${{ valid: false, kind: 'invalid' }} + ${'alice@example'} | ${{ valid: true }} + ${'alice@example.com'} | ${{ valid: true }} + `('test email: $email', ({ email, expected }) => { + expect(requiredEmailLike(email)()).toEqual( + expect.objectContaining(expected) + ); + }); +}); diff --git a/web/packages/shared/components/Validation/rules.ts b/web/packages/shared/components/Validation/rules.ts index d4c6730a91220..b2dc163f4197e 100644 --- a/web/packages/shared/components/Validation/rules.ts +++ b/web/packages/shared/components/Validation/rules.ts @@ -14,6 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ +/** + * The result of validating a field. + */ +export interface ValidationResult { + valid: boolean; + message?: string; +} + +/** + * A function to validate a field value. + */ +export type Rule = (value: T) => () => R; + /** * requiredField checks for empty strings and arrays. * @@ -21,8 +34,8 @@ limitations under the License. * @param value The value user entered. */ const requiredField = - (message: string) => - (value: string | T[]) => + (message: string): Rule => + value => () => { const valid = !(!value || value.length === 0); return { @@ -31,7 +44,7 @@ const requiredField = }; }; -const requiredToken = (value: string) => () => { +const requiredToken: Rule = (value: string) => () => { if (!value || value.length === 0) { return { valid: false, @@ -44,7 +57,7 @@ const requiredToken = (value: string) => () => { }; }; -const requiredPassword = (value: string) => () => { +const requiredPassword: Rule = (value: string) => () => { if (!value || value.length < 6) { return { valid: false, @@ -58,7 +71,9 @@ const requiredPassword = (value: string) => () => { }; const requiredConfirmedPassword = - (password: string) => (confirmedPassword: string) => () => { + (password: string): Rule => + (confirmedPassword: string) => + () => { if (!confirmedPassword) { return { valid: false, @@ -81,7 +96,7 @@ const requiredConfirmedPassword = // requiredRoleArn checks provided arn (AWS role name) is somewhat // in the format as documented here: // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html -const requiredRoleArn = (roleArn: string) => () => { +const requiredRoleArn: Rule = (roleArn: string) => () => { let parts = []; if (roleArn) { parts = roleArn.split(':role'); @@ -105,10 +120,42 @@ const requiredRoleArn = (roleArn: string) => () => { }; }; +export interface EmailValidationResult extends ValidationResult { + kind?: 'empty' | 'invalid'; +} + +// requiredEmailLike ensures a string contains a plausible email, i.e. that it +// contains an '@' and some characters on each side. +const requiredEmailLike: Rule = + (email: string) => () => { + if (!email) { + return { + valid: false, + kind: 'empty', + message: 'Email address is required', + }; + } + + // Must contain an @, i.e. 2 entries, and each must be nonempty. + let parts = email.split('@'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return { + valid: false, + kind: 'invalid', + message: `Email address '${email}' is invalid`, + }; + } + + return { + valid: true, + }; + }; + export { requiredToken, requiredPassword, requiredConfirmedPassword, requiredField, requiredRoleArn, + requiredEmailLike, }; diff --git a/web/packages/teleport/src/Main/Main.test.tsx b/web/packages/teleport/src/Main/Main.test.tsx index 101664c217558..06b26bf81b2d2 100644 --- a/web/packages/teleport/src/Main/Main.test.tsx +++ b/web/packages/teleport/src/Main/Main.test.tsx @@ -98,3 +98,47 @@ test('renders without questionnaire prop', () => { expect(screen.getByTestId('title')).toBeInTheDocument(); }); + +test('displays invite collaborators feedback if present', () => { + mockUserContextProviderWith(makeTestUserContext()); + const ctx = setupContext(); + + const props: MainProps = { + features: getOSSFeatures(), + inviteCollaboratorsFeedback:
Passed Component!
, + }; + + render( + + + +
+ + + + ); + + expect(screen.getByText('Passed Component!')).toBeInTheDocument(); +}); + +test('renders without invite collaborators feedback enabled', () => { + mockUserContextProviderWith(makeTestUserContext()); + const ctx = setupContext(); + + const props: MainProps = { + features: getOSSFeatures(), + }; + expect(props.inviteCollaboratorsFeedback).toBeUndefined(); + + render( + + + +
+ + + + ); + + expect(screen.getByTestId('title')).toBeInTheDocument(); +}); diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx index 1b6b1f69ea8ab..c8c877b27c782 100644 --- a/web/packages/teleport/src/Main/Main.tsx +++ b/web/packages/teleport/src/Main/Main.tsx @@ -69,6 +69,7 @@ export interface MainProps { billingBanners?: ReactNode[]; Questionnaire?: (props: QuestionnaireProps) => React.ReactElement; navigationProps?: NavigationProps; + inviteCollaboratorsFeedback?: ReactNode; } export function Main(props: MainProps) { @@ -194,6 +195,7 @@ export function Main(props: MainProps) { /> )} + {props.inviteCollaboratorsFeedback} ); } diff --git a/web/packages/teleport/src/Users/Users.story.tsx b/web/packages/teleport/src/Users/Users.story.tsx index 93b7917c98588..555f0e1fb1006 100644 --- a/web/packages/teleport/src/Users/Users.story.tsx +++ b/web/packages/teleport/src/Users/Users.story.tsx @@ -100,13 +100,17 @@ const sample = { type: 'none', user: null, } as any, + inviteCollaboratorsOpen: false, onStartCreate: () => null, onStartDelete: () => null, onStartEdit: () => null, onStartReset: () => null, + onStartInviteCollaborators: () => null, onClose: () => null, onCreate: () => null, onDelete: () => null, onUpdate: () => null, onReset: () => null, + onInviteCollaboratorsClose: () => null, + InviteCollaborators: null, }; diff --git a/web/packages/teleport/src/Users/Users.test.tsx b/web/packages/teleport/src/Users/Users.test.tsx new file mode 100644 index 0000000000000..8e88a81f3639d --- /dev/null +++ b/web/packages/teleport/src/Users/Users.test.tsx @@ -0,0 +1,102 @@ +/** + * 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 { MemoryRouter } from 'react-router'; +import { render, screen } from 'design/utils/testing'; + +import { ContextProvider } from 'teleport'; +import { createTeleportContext } from 'teleport/mocks/contexts'; + +import { Users } from './Users'; +import { State } from './useUsers'; + +describe('invite collaborators integration', () => { + const ctx = createTeleportContext(); + + let props: State; + beforeEach(() => { + props = { + attempt: { + message: 'success', + isSuccess: true, + isProcessing: false, + isFailed: false, + }, + users: [], + roles: [], + operation: { type: 'invite-collaborators' }, + + onStartCreate: () => undefined, + onStartDelete: () => undefined, + onStartEdit: () => undefined, + onStartReset: () => undefined, + onStartInviteCollaborators: () => undefined, + onClose: () => undefined, + onDelete: () => undefined, + onCreate: () => undefined, + onUpdate: () => undefined, + onReset: () => undefined, + onInviteCollaboratorsClose: () => undefined, + InviteCollaborators: null, + inviteCollaboratorsOpen: false, + }; + }); + + test('displays the Create New User button when not configured', async () => { + render( + + + + + + ); + + expect(screen.getByText('Create New User')).toBeInTheDocument(); + expect(screen.queryByText('Enroll Users')).not.toBeInTheDocument(); + }); + + test('displays the Enroll Users button when configured', async () => { + const startMock = jest.fn(); + props = { + ...props, + InviteCollaborators: () => ( +
Invite Collaborators
+ ), + onStartInviteCollaborators: startMock, + }; + + render( + + + + + + ); + + const enrollButton = screen.getByText('Enroll Users'); + expect(enrollButton).toBeInTheDocument(); + expect(screen.queryByText('Create New User')).not.toBeInTheDocument(); + + enrollButton.click(); + expect(startMock.mock.calls).toHaveLength(1); + + // This will display regardless since the dialog display is managed by the + // dialog itself, and our mock above is trivial, but we can make sure it + // renders. + expect(screen.getByTestId('invite-collaborators')).toBeInTheDocument(); + }); +}); diff --git a/web/packages/teleport/src/Users/Users.tsx b/web/packages/teleport/src/Users/Users.tsx index bdf15273a0f4f..de0c0cad35939 100644 --- a/web/packages/teleport/src/Users/Users.tsx +++ b/web/packages/teleport/src/Users/Users.tsx @@ -27,10 +27,10 @@ import UserList from './UserList'; import UserAddEdit from './UserAddEdit'; import UserDelete from './UserDelete'; import UserReset from './UserReset'; -import useUsers, { State } from './useUsers'; +import useUsers, { State, UsersContainerProps } from './useUsers'; -export default function Container() { - const state = useUsers(); +export default function Container(props: UsersContainerProps) { + const state = useUsers(props); return ; } @@ -49,16 +49,32 @@ export function Users(props: State) { onUpdate, onDelete, onReset, + onStartInviteCollaborators, + onInviteCollaboratorsClose, + inviteCollaboratorsOpen, + InviteCollaborators, } = props; - return ( Users {attempt.isSuccess && ( - - Create New User - + <> + {!InviteCollaborators && ( + + Create New User + + )} + {InviteCollaborators && ( + + Enroll Users + + )} + )} {attempt.isProcessing && ( @@ -99,6 +115,12 @@ export function Users(props: State) { username={operation.user.name} /> )} + {InviteCollaborators && ( + + )} ); } diff --git a/web/packages/teleport/src/Users/useUsers.ts b/web/packages/teleport/src/Users/useUsers.ts index ae150d920c1e2..2e06045370148 100644 --- a/web/packages/teleport/src/Users/useUsers.ts +++ b/web/packages/teleport/src/Users/useUsers.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { useState, useEffect } from 'react'; +import { ReactElement, useState, useEffect } from 'react'; import { useAttempt } from 'shared/hooks'; import { User } from 'teleport/services/user'; import useTeleport from 'teleport/useTeleport'; -export default function useUsers() { +export default function useUsers({ InviteCollaborators }: UsersContainerProps) { const ctx = useTeleport(); const [attempt, attemptActions] = useAttempt({ isProcessing: true }); const [users, setUsers] = useState([] as User[]); @@ -28,6 +28,8 @@ export default function useUsers() { const [operation, setOperation] = useState({ type: 'none', } as Operation); + const [inviteCollaboratorsOpen, setInviteCollaboratorsOpen] = + useState(false); function onStartCreate() { const user = { name: '', roles: [], created: new Date() }; @@ -49,6 +51,11 @@ export default function useUsers() { setOperation({ type: 'reset', user }); } + function onStartInviteCollaborators(user: User) { + setOperation({ type: 'invite-collaborators', user }); + setInviteCollaboratorsOpen(true); + } + function onClose() { setOperation({ type: 'none' }); } @@ -77,6 +84,15 @@ export default function useUsers() { .then(() => ctx.userService.createResetPasswordToken(u.name, 'invite')); } + function onInviteCollaboratorsClose(newUsers?: User[]) { + if (newUsers && newUsers.length > 0) { + setUsers([...newUsers, ...users]); + } + + setInviteCollaboratorsOpen(false); + setOperation({ type: 'none' }); + } + useEffect(() => { function fetchRoles() { if (ctx.getFeatureFlags().roles) { @@ -105,17 +121,36 @@ export default function useUsers() { onStartDelete, onStartEdit, onStartReset, + onStartInviteCollaborators, onClose, onDelete, onCreate, onUpdate, onReset, + onInviteCollaboratorsClose, + InviteCollaborators, + inviteCollaboratorsOpen, }; } type Operation = { - type: 'create' | 'edit' | 'delete' | 'reset' | 'none'; + type: + | 'create' + | 'invite-collaborators' + | 'edit' + | 'delete' + | 'reset' + | 'none'; user?: User; }; +export interface InviteCollaboratorsDialogProps { + onClose: (users?: User[]) => void; + open: boolean; +} + +export type UsersContainerProps = { + InviteCollaborators?: (props: InviteCollaboratorsDialogProps) => ReactElement; +}; + export type State = ReturnType; diff --git a/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.test.tsx b/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.test.tsx index e788f685c7524..3ab287bc1e478 100644 --- a/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.test.tsx +++ b/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.test.tsx @@ -176,3 +176,18 @@ test('renders questionnaire', () => { expect(screen.getByText(/Passed Component!/i)).toBeInTheDocument(); }); + +test('renders invite collaborators', () => { + mockUserContextProviderWith(makeTestUserContext()); + + const props = makeProps(); + props.fetchAttempt = { status: 'success' }; + props.success = true; + props.recoveryCodes = undefined; + props.displayInviteCollaborators = true; + props.setDisplayInviteCollaborators = () => {}; + props.InviteCollaborators = () =>
Passed Component!
; + render(); + + expect(screen.getByText(/Passed Component!/i)).toBeInTheDocument(); +}); diff --git a/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.tsx b/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.tsx index 49a6f7ec6d958..c2f6b09a15063 100644 --- a/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.tsx +++ b/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.tsx @@ -66,6 +66,9 @@ export function NewCredentials(props: NewCredentialsProps) { displayOnboardingQuestionnaire = false, setDisplayOnboardingQuestionnaire = false, Questionnaire = undefined, + displayInviteCollaborators = false, + setDisplayInviteCollaborators = null, + InviteCollaborators = undefined, } = props; // Check which flow to render as default. @@ -94,6 +97,22 @@ export function NewCredentials(props: NewCredentialsProps) { ); } + if ( + success && + !resetMode && + displayInviteCollaborators && + setDisplayInviteCollaborators && + InviteCollaborators + ) { + return ( + + setDisplayInviteCollaborators(false)} + /> + + ); + } + if ( success && !resetMode && diff --git a/web/packages/teleport/src/Welcome/NewCredentials/types.ts b/web/packages/teleport/src/Welcome/NewCredentials/types.ts index d197066f3f630..268572924ac5c 100644 --- a/web/packages/teleport/src/Welcome/NewCredentials/types.ts +++ b/web/packages/teleport/src/Welcome/NewCredentials/types.ts @@ -48,6 +48,12 @@ export type QuestionnaireProps = { onSubmit?: () => void; }; +// Note: InviteCollaboratorsCardProps is duplicated in Enterprise +// (e-teleport/Welcome/InviteCollaborators/InviteCollaborators) +export type InviteCollaboratorsCardProps = { + onSubmit: () => void; +}; + export type NewCredentialsProps = UseTokenState & { resetMode?: boolean; isDashboard: boolean; @@ -60,6 +66,13 @@ export type NewCredentialsProps = UseTokenState & { username, onSubmit, }: QuestionnaireProps) => ReactElement; + + // support for E's invite collaborators at onboarding + displayInviteCollaborators?: boolean; + setDisplayInviteCollaborators?: (bool: boolean) => void; + InviteCollaborators?: ({ + onSubmit, + }: InviteCollaboratorsCardProps) => ReactElement; }; export type RegisterSuccessProps = { diff --git a/web/packages/teleport/src/Welcome/Welcome.tsx b/web/packages/teleport/src/Welcome/Welcome.tsx index 643f753b81f8a..933caac5cc726 100644 --- a/web/packages/teleport/src/Welcome/Welcome.tsx +++ b/web/packages/teleport/src/Welcome/Welcome.tsx @@ -18,11 +18,17 @@ import React from 'react'; import { WelcomeWrapper } from 'design/Onboard/WelcomeWrapper'; -import { Route, Switch, useParams } from 'teleport/components/Router'; +import { + Route, + Switch, + useParams, + useLocation, +} from 'teleport/components/Router'; import history from 'teleport/services/history'; import cfg from 'teleport/config'; import { NewCredentialsContainerProps } from 'teleport/Welcome/NewCredentials'; +import { CLOUD_INVITE_URL_PARAM } from './const'; import { CardWelcome } from './CardWelcome'; type WelcomeProps = { @@ -31,9 +37,17 @@ type WelcomeProps = { export default function Welcome({ NewCredentials }: WelcomeProps) { const { tokenId } = useParams<{ tokenId: string }>(); + const { search } = useLocation(); const handleOnInviteContinue = () => { - history.push(cfg.getUserInviteTokenContinueRoute(tokenId)); + // We need to pass through the `invite` query parameter (if it exists) to + // render the invite collaborators form for Cloud users. + let suffix = ''; + if (new URLSearchParams(search).has(CLOUD_INVITE_URL_PARAM)) { + suffix = `?${CLOUD_INVITE_URL_PARAM}`; + } + + history.push(`${cfg.getUserInviteTokenContinueRoute(tokenId)}${suffix}`); }; const handleOnResetContinue = () => { diff --git a/web/packages/teleport/src/Welcome/const.ts b/web/packages/teleport/src/Welcome/const.ts new file mode 100644 index 0000000000000..f87fb0fa5dfd8 --- /dev/null +++ b/web/packages/teleport/src/Welcome/const.ts @@ -0,0 +1,17 @@ +/* +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 CLOUD_INVITE_URL_PARAM = 'invite'; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 7b112eb133965..b57af3ffc49d2 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -183,6 +183,7 @@ const cfg = { createPrivilegeTokenPath: '/v1/webapi/users/privilege/token', rolesPath: '/v1/webapi/roles/:name?', + presetRolesPath: '/v1/webapi/presetroles', githubConnectorsPath: '/v1/webapi/github/:name?', trustedClustersPath: '/v1/webapi/trustedcluster/:name?', @@ -661,6 +662,10 @@ const cfg = { return generatePath(cfg.api.rolesPath, { name }); }, + getPresetRolesUrl() { + return cfg.api.presetRolesPath; + }, + getKubernetesUrl(clusterId: string, params: UrlResourcesParams) { return generateResourcePath(cfg.api.kubernetesPath, { clusterId, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 8f68a9cd38f15..11c928e098d6b 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -302,7 +302,7 @@ export class FeatureUsers implements TeleportFeature { title: 'Manage Users', path: cfg.routes.users, exact: true, - component: Users, + component: () => , }; hasAccess(flags: FeatureFlags): boolean { @@ -317,6 +317,10 @@ export class FeatureUsers implements TeleportFeature { return cfg.getUsersRoute(); }, }; + + getRoute() { + return this.route; + } } export class FeatureRoles implements TeleportFeature { diff --git a/web/packages/teleport/src/services/localStorage/localStorage.ts b/web/packages/teleport/src/services/localStorage/localStorage.ts index 21a4e3ec37ce1..57a062cf8ff65 100644 --- a/web/packages/teleport/src/services/localStorage/localStorage.ts +++ b/web/packages/teleport/src/services/localStorage/localStorage.ts @@ -25,7 +25,7 @@ import { UserPreferences, } from 'teleport/services/userPreferences/types'; -import { KeysEnum, LocalStorageSurvey } from './types'; +import { CloudUserInvites, KeysEnum, LocalStorageSurvey } from './types'; import type { RecommendFeature } from 'teleport/types'; @@ -141,6 +141,24 @@ const storage = { window.localStorage.removeItem(KeysEnum.ONBOARD_SURVEY); }, + getCloudUserInvites(): CloudUserInvites { + const invites = window.localStorage.getItem(KeysEnum.CLOUD_USER_INVITES); + if (invites) { + return JSON.parse(invites); + } + return null; + }, + + setCloudUserInvites(invites: CloudUserInvites) { + const json = JSON.stringify(invites); + + window.localStorage.setItem(KeysEnum.CLOUD_USER_INVITES, json); + }, + + clearCloudUserInvites() { + window.localStorage.removeItem(KeysEnum.CLOUD_USER_INVITES); + }, + getThemePreference(): ThemePreference { const userPreferences = storage.getUserPreferences(); if (userPreferences) { diff --git a/web/packages/teleport/src/services/localStorage/types.ts b/web/packages/teleport/src/services/localStorage/types.ts index f9a48f797f602..635726d7a5daf 100644 --- a/web/packages/teleport/src/services/localStorage/types.ts +++ b/web/packages/teleport/src/services/localStorage/types.ts @@ -25,6 +25,7 @@ export const KeysEnum = { USER_PREFERENCES: 'grv_teleport_user_preferences', ONBOARD_SURVEY: 'grv_teleport_onboard_survey', RECOMMEND_FEATURE: 'grv_recommend_feature', + CLOUD_USER_INVITES: 'grv_teleport_cloud_user_invites', }; // SurveyRequest is the request for sending data to the back end @@ -49,3 +50,10 @@ export type LocalStorageMarketingParams = { medium: string; intent: string; }; + +// CloudUserInvites is a set of users and roles which should be submitted after +// initial login. +export type CloudUserInvites = { + recipients: Array; + roles: Array; +}; diff --git a/web/packages/teleport/src/services/resources/makeResource.ts b/web/packages/teleport/src/services/resources/makeResource.ts index fca283a883573..f122e2f2a684f 100644 --- a/web/packages/teleport/src/services/resources/makeResource.ts +++ b/web/packages/teleport/src/services/resources/makeResource.ts @@ -24,6 +24,7 @@ export function makeResource(json: any): Resource { kind: json.kind, name: json.name, content: json.content, + description: json.description, }; } diff --git a/web/packages/teleport/src/services/resources/resource.ts b/web/packages/teleport/src/services/resources/resource.ts index 2769d0bbd94cb..d150c567c0e9d 100644 --- a/web/packages/teleport/src/services/resources/resource.ts +++ b/web/packages/teleport/src/services/resources/resource.ts @@ -38,6 +38,12 @@ class ResourceService { .then(res => makeResourceList<'role'>(res)); } + fetchPresetRoles() { + return api + .get(cfg.getPresetRolesUrl()) + .then(res => makeResourceList<'role'>(res)); + } + createTrustedCluster(content: string) { return api .post(cfg.getTrustedClustersUrl(), { content }) diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index 67fd6cf059b13..15f323a415574 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -18,6 +18,7 @@ export type Resource = { id: string; kind: T; name: string; + description?: string; // content is config in yaml format. content: string; }; From 204f7aa1d2c4437a4e9b5c878f90af26da73010a Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Sun, 12 Nov 2023 19:32:21 -0700 Subject: [PATCH 2/2] Small compat fix for v13 due to lack of `WithUnauthenticatedHighLimiter` --- lib/web/apiserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 64bd93ebe4839..722ad5f5b5dd4 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -715,7 +715,7 @@ func (h *Handler) bindDefaultEndpoints() { h.POST("/webapi/roles", h.WithAuth(h.upsertRoleHandle)) h.PUT("/webapi/roles/:name", h.WithAuth(h.upsertRoleHandle)) h.DELETE("/webapi/roles/:name", h.WithAuth(h.deleteRole)) - h.GET("/webapi/presetroles", h.WithUnauthenticatedHighLimiter(h.getPresetRoles)) + h.GET("/webapi/presetroles", httplib.MakeHandler(h.getPresetRoles)) h.GET("/webapi/github", h.WithAuth(h.getGithubConnectorsHandle)) h.POST("/webapi/github", h.WithAuth(h.upsertGithubConnectorHandle))