Move the shared part of access requests UI to OSS (#41036)

* Move `teleport.e/AccessRequests` to `shared/components/AccessRequests`

The last commit from teleport.e that includes the original files: gravitational/teleport.e@5ca6881e29

* Move `teleport.e/services/accessRequests` to `shared/services/AccessRequests`

* Move `teleterm.e/ui` to `teleterm/ui`

* `make fix-license`

* Fix imports

* Run prettier and eslint

* Rename stories

* Remove ts-ignore comments

`accessRequestsService.test.ts` was refactored slightly,
we don't need assumed requests there at all.
This commit is contained in:
Grzegorz Zdunek
2024-04-30 18:08:38 +00:00
committed by GitHub
parent 07254a5def
commit 3ee2c51564
93 changed files with 21229 additions and 52 deletions
@@ -0,0 +1,101 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState, useEffect } from 'react';
import { Flex, LabelInput, Text } from 'design';
import Select, { Option } from 'shared/components/Select';
import { ToolTipInfo } from 'shared/components/ToolTip';
import { AccessRequest } from 'shared/services/accessRequests';
import {
getDurationOptionIndexClosestToOneWeek,
getDurationOptionsFromStartTime,
} from './durationOptions';
export function AccessDurationRequest({
assumeStartTime,
accessRequest,
maxDuration,
setMaxDuration,
}: {
assumeStartTime: Date;
accessRequest: AccessRequest;
maxDuration: Option<number>;
setMaxDuration(s: Option<number>): void;
}) {
// Options for extending or shortening the access request duration.
const [durationOptions, setDurationOptions] = useState<Option<number>[]>([]);
useEffect(() => {
if (!assumeStartTime) {
defaultDuration();
} else {
updateAccessDuration(assumeStartTime);
}
}, [assumeStartTime]);
function defaultDuration() {
const created = accessRequest.created;
const options = getDurationOptionsFromStartTime(created, accessRequest);
setDurationOptions(options);
if (options.length > 0) {
const durationIndex = getDurationOptionIndexClosestToOneWeek(
options,
accessRequest.created
);
setMaxDuration(options[durationIndex]);
}
}
function updateAccessDuration(start: Date) {
const updatedDurationOpts = getDurationOptionsFromStartTime(
start,
accessRequest
);
const durationIndex = getDurationOptionIndexClosestToOneWeek(
updatedDurationOpts,
start
);
setMaxDuration(updatedDurationOpts[durationIndex]);
setDurationOptions(updatedDurationOpts);
}
return (
<LabelInput typography="body2" color="text.slightlyMuted">
<Flex alignItems="center">
<Text mr={1}>Access Duration</Text>
<ToolTipInfo>
How long you would be given elevated privileges. Note that the time it
takes to approve this request will be subtracted from the duration you
requested.
</ToolTipInfo>
</Flex>
<Select
options={durationOptions}
onChange={setMaxDuration}
value={maxDuration}
/>
</LabelInput>
);
}
@@ -0,0 +1,49 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Flex, Text } from 'design';
import { ToolTipInfo } from 'shared/components/ToolTip';
import { AccessRequest } from 'shared/services/accessRequests';
import { getFormattedDurationTxt } from '../Shared/utils';
export function AccessDurationReview({
assumeStartTime,
accessRequest,
}: {
assumeStartTime: Date;
accessRequest: AccessRequest;
}) {
return (
<Flex alignItems="center">
<Text mr={1}>
<b>Access Duration: </b>
{getFormattedDurationTxt({
start: assumeStartTime || accessRequest.assumeStartTime || new Date(),
end: accessRequest.expires,
})}
</Text>
<ToolTipInfo>
How long the access will be granted for after approval.
</ToolTipInfo>
</Flex>
);
}
@@ -0,0 +1,100 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { addHours, addWeeks, addDays } from 'date-fns';
import {
getDurationOptionIndexClosestToOneWeek,
DurationOption,
} from './durationOptions';
describe('getDurationOptionIndexClosestToOneWeek', () => {
const beginDate = new Date('2024-02-10T03:00:00.000000Z');
jest.useFakeTimers().setSystemTime(beginDate);
const durationOpts: DurationOption[] = [
{ value: beginDate.getTime(), label: '' }, // earliest date
{ value: addHours(beginDate, 3).getTime(), label: '' },
{ value: addHours(beginDate, 6).getTime(), label: '' },
{ value: addHours(beginDate, 9).getTime(), label: '' },
{ value: addDays(beginDate, 3).getTime(), label: '' },
{ value: addDays(beginDate, 7).getTime(), label: '' }, // one week
{ value: addDays(beginDate, 8).getTime(), label: '' },
{ value: addDays(beginDate, 10).getTime(), label: '' },
{ value: addWeeks(beginDate, 2).getTime(), label: '' }, // two week
];
const lastDurationIndex = durationOpts.length - 1;
test('one week from selected date, is greater than value from last index, returns the last index', () => {
const startDate = addDays(beginDate, 10);
const index = getDurationOptionIndexClosestToOneWeek(
durationOpts,
startDate // 1 week from startDate is 17 days, past 2 weeks.
);
expect(index).toBe(lastDurationIndex);
});
test('one week from selected date, is equal to the value from last index, returns the last index', () => {
const startDate = addWeeks(beginDate, 1);
// Ensure the expected option is what we expect.
expect(durationOpts[lastDurationIndex].value).toBe(
addWeeks(startDate, 1).getTime()
);
const index = getDurationOptionIndexClosestToOneWeek(
durationOpts,
startDate // 1 week from start date is exactly 2 weeks
);
expect(index).toBe(lastDurationIndex);
});
test('one week from selected date, is less than the last index, returns the index equal to one week', () => {
const startDate = beginDate;
const expectedIndex = 5;
// Ensure the expected option is what we expect.
expect(durationOpts[expectedIndex].value).toBe(
addWeeks(beginDate, 1).getTime() // 1 week from start date is exactly 1 week
);
const index = getDurationOptionIndexClosestToOneWeek(
durationOpts,
startDate
);
expect(index).toBe(expectedIndex);
});
test('one week from selected date, is less than the last index, returns the index closest but no greater than one week', () => {
const startDate = addDays(beginDate, 3);
const expectedIndex = 7;
// Ensure the expected option is what we expect.
expect(durationOpts[expectedIndex].value).toBe(
addDays(beginDate, 10).getTime() // 1 week from start date is day 10
);
const index = getDurationOptionIndexClosestToOneWeek(
durationOpts,
startDate
);
expect(index).toBe(expectedIndex);
});
});
@@ -0,0 +1,176 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import {
addHours,
addDays,
isAfter,
addWeeks,
differenceInHours,
} from 'date-fns';
import { Option } from 'shared/components/Select';
import { AccessRequest } from 'shared/services/accessRequests';
import { getFormattedDurationTxt } from '../Shared/utils';
// Preset hour options for the access duration dropdown.
export const PRESET_HOURS = [1, 2, 3, 4, 6, 8, 12, 18];
// Preset day options, up to the maximum possible duration, for
// the access duration dropdown. The backend maximum duration
// is two weeks.
export const PRESET_DAYS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
export type DurationOption = Option<number>;
function getMaxDurationOption(
startDateTime: Date,
maxDuration: Date
): Option<number> {
return {
value: maxDuration.getTime(),
label: getFormattedDurationTxt({
start: startDateTime,
end: maxDuration,
}),
};
}
/**
* Calculates the selectable access duration options depending
* on the selected start time. The duration range is starting from the
* "selected start time" to the request's "max duration".
*
* Access duration refers to how long access should last after
* start date/time.
*/
export function getDurationOptionsFromStartTime(
start: Date,
accessRequest: AccessRequest
): DurationOption[] {
// The setSeconds(0,0) removes the seconds and milliseconds since
// `startDateTime` is constructed without them. Makes comparing difference
// in hours ignore the small time difference.
const createdTimestamp = new Date(accessRequest.created).setSeconds(0, 0);
const maxDurationTimestamp = new Date(accessRequest.maxDuration).setSeconds(
0,
0
);
if (
start.getTime() >= maxDurationTimestamp ||
start.getTime() < createdTimestamp
) {
return [];
}
let durationOpts: Option<number>[] = [];
const totalHoursDiff = differenceInHours(
maxDurationTimestamp,
start.getTime(),
{
roundingMethod: 'ceil',
}
);
// If there is less than an hour available for access, return
// it as max duration as the only option.
if (totalHoursDiff <= 1) {
durationOpts.push(getMaxDurationOption(start, accessRequest.maxDuration));
return durationOpts;
}
// Add preset hour options up to maximum allowed.
for (const hour of PRESET_HOURS) {
const updatedDateTime = addHours(start, hour);
if (isAfter(updatedDateTime, accessRequest.maxDuration)) {
break;
}
durationOpts.push({
value: updatedDateTime.getTime(),
label: getFormattedDurationTxt({
start,
end: updatedDateTime,
}),
});
}
// Add preset days up to maximum allowed.
if (totalHoursDiff >= 24) {
for (const day of PRESET_DAYS) {
const updatedEndDate = addDays(start, day);
if (isAfter(updatedEndDate, accessRequest.maxDuration)) {
break;
}
durationOpts.push({
value: updatedEndDate.getTime(),
label: getFormattedDurationTxt({
start,
end: updatedEndDate,
}),
});
}
}
const lastDurationOption = durationOpts[durationOpts.length - 1];
if (maxDurationTimestamp > lastDurationOption.value) {
durationOpts.push(getMaxDurationOption(start, accessRequest.maxDuration));
}
return durationOpts;
}
// Goes through the given duration options and returns the index
// that is closest to one week from given start date.
// It was decided that one week is a good default duration
// to pre-select for the user for the following duration types:
// - Access duration ranges can go as high as 14 days.
// - Pending request duration (how long the request should be
// in the pending state before it expires) can go as high as
// 7 days. The use of this function for this case just guards
// against future increase.
export function getDurationOptionIndexClosestToOneWeek(
durationOptions: DurationOption[],
startDate: Date
) {
const oneWeekFromSelectedTime = addWeeks(startDate, 1).getTime();
const lastDurationIndex = durationOptions.length - 1;
// Default to the last option, since that is the max the user can get.
if (oneWeekFromSelectedTime >= durationOptions[lastDurationIndex].value) {
return lastDurationIndex;
}
// Find an option that is nearest to one week, but no greater.
let closestIndex = 0;
for (let i = 0; i < durationOptions.length; i++) {
const currentTime = durationOptions[i].value;
if (currentTime === oneWeekFromSelectedTime) {
closestIndex = i;
break;
} else if (currentTime > oneWeekFromSelectedTime) {
// the last stored index was closest to the one week but no greater
break;
}
closestIndex = i;
}
return closestIndex;
}
@@ -0,0 +1,192 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { addDays } from 'date-fns';
import { Option } from 'shared/components/Select';
import { AccessRequest } from 'shared/services/accessRequests';
import {
getDurationOptionsFromStartTime,
PRESET_DAYS,
PRESET_HOURS,
} from './durationOptions';
test('duration difference is less than an hour returns only the max duration', () => {
const created = new Date('2024-02-16T03:00:00.156944Z');
const maxDuration = new Date('2024-02-16T03:45:00.156944Z');
const selectedDate = new Date(created);
selectedDate.setHours(
3 /* hours */,
0 /* minutes */,
0 /* sec */,
0 /* ms */
);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const opts = getDurationOptionsFromStartTime(selectedDate, mockAccessRequest);
// Only one option, the max duration.
expect(opts).toHaveLength(1);
expect(opts[0].value).toBe(maxDuration.getTime());
expect(opts[0].label).toBe('45 minutes');
});
test('duration difference is 1hr 30min, returns an hour option and the max duration', () => {
const created = new Date('2024-02-16T03:00:00.156944Z');
const maxDuration = new Date('2024-02-16T04:30:00.156944Z');
const selectedDate = new Date(created);
selectedDate.setHours(
3 /* hours */,
0 /* minutes */,
0 /* sec */,
0 /* ms */
);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const opts = getDurationOptionsFromStartTime(selectedDate, mockAccessRequest);
expect(opts).toHaveLength(2);
// one preset hour
expect(opts[0].value).toBe(
new Date(selectedDate).setHours(
selectedDate.getHours() + PRESET_HOURS[0],
0,
0,
0
)
);
expect(opts[0].label).toBe('1 hour');
// max duration
expect(opts[1].value).toBe(maxDuration.getTime());
expect(opts[1].label).toBe('1 hour 30 minutes');
});
test('defining all preset hours', () => {
expect(PRESET_HOURS).toHaveLength(8);
const created = new Date('2024-02-16T03:00:00.156944Z');
const maxDuration = new Date('2024-02-16T21:00:00.156944Z');
const selectedDate = new Date(created);
selectedDate.setHours(
3 /* hours */,
0 /* minutes */,
0 /* sec */,
0 /* ms */
);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const opts = getDurationOptionsFromStartTime(selectedDate, mockAccessRequest);
expect(opts).toHaveLength(PRESET_HOURS.length);
testPresetHours(opts, selectedDate);
});
test('defining all preset days + preset hours + maxest duration', () => {
expect(PRESET_HOURS).toHaveLength(8);
expect(PRESET_DAYS).toHaveLength(14);
const created = new Date('2024-02-11T03:00:00.156944Z');
const maxDuration = new Date('2024-02-25T03:30:00.156944Z');
const selectedDate = new Date(created);
selectedDate.setHours(
3 /* hours */,
0 /* minutes */,
0 /* sec */,
0 /* ms */
);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const opts = getDurationOptionsFromStartTime(selectedDate, mockAccessRequest);
expect(opts).toHaveLength(PRESET_HOURS.length + PRESET_DAYS.length + 1);
testPresetHours(opts, selectedDate);
const startDateTime = new Date(created);
startDateTime.setHours(selectedDate.getHours(), 0, 0, 0);
for (let i = 0; i < PRESET_DAYS.length; i += 1) {
const optionIndex = i + PRESET_HOURS.length;
expect(opts[optionIndex].value).toBe(
addDays(startDateTime, PRESET_DAYS[i]).getTime()
);
const dayTxt = i ? 'days' : 'day';
expect(opts[optionIndex].label).toBe(`${PRESET_DAYS[i]} ${dayTxt}`);
}
// Test maxest duration.
expect(opts[opts.length - 1].value).toBe(maxDuration.getTime());
expect(opts[opts.length - 1].label).toBe('14 days 30 minutes');
});
function testPresetHours(opts: Option<number>[], selectedDate: Date) {
// one preset hour
for (let i = 0; i < PRESET_HOURS.length; i += 1) {
expect(opts[i].value).toBe(
new Date(selectedDate).setHours(
selectedDate.getHours() + PRESET_HOURS[i],
0, // min
0, // sec
0 // ms
)
);
const hourTxt = i ? 'hours' : 'hour';
expect(opts[i].label).toBe(`${PRESET_HOURS[i]} ${hourTxt}`);
}
}
const mockAccessRequest: AccessRequest = {
id: '31a711f6-f53a-4d61-baae-2c3c8d9a3fd9',
state: 'PENDING',
resolveReason: '',
requestReason: '',
user: 'lisa',
roles: ['@teleport-access-approver'],
created: new Date('2024-02-16T03:00:08.156944Z'),
createdDuration: '',
expires: new Date('2024-02-19T03:00:08.157365Z'),
expiresDuration: '',
maxDuration: new Date('2024-02-19T03:00:08.157365Z'),
maxDurationText: '',
requestTTL: new Date('2024-02-16T04:00:08.157365Z'),
requestTTLDuration: '',
sessionTTL: new Date('2024-02-16T12:11:46.99997Z'),
sessionTTLDuration: '',
reviews: [],
reviewers: [],
thresholdNames: ['default'],
resources: [],
assumeStartTime: null,
};
@@ -0,0 +1,20 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { AccessDurationRequest } from './AccessDurationRequest';
export { AccessDurationReview } from './AccessDurationReview';
@@ -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 React, { useState } from 'react';
import { format } from 'date-fns';
import { Box, Text } from 'design';
import { Option } from 'shared/components/Select';
import cfg from 'shared/config';
import { dryRunResponse } from '../fixtures';
import { AccessDurationRequest, AccessDurationReview } from '../AccessDuration';
import { AssumeStartTime } from './AssumeStartTime';
export default {
title: 'Shared/AccessRequests/AssumeStartTime',
};
export const NewRequest = () => {
const [start, setStart] = useState<Date>();
const [maxDuration, setMaxDuration] = useState<Option<number>>();
return (
<Box width="400px">
<Box mb={4}>
<Text>Sample Dry Run Access Requeset Response:</Text>
<Text>
<b>Created Date:</b>{' '}
{format(dryRunResponse.created, cfg.dateTimeFormat)}
</Text>
<Text>
<b>Max Duration Date:</b>{' '}
{format(dryRunResponse.maxDuration, cfg.dateTimeFormat)}
</Text>
</Box>
<AssumeStartTime
start={start}
onStartChange={setStart}
accessRequest={dryRunResponse}
/>
<AccessDurationRequest
assumeStartTime={start}
accessRequest={dryRunResponse}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
/>
</Box>
);
};
export const CreatedRequestWithoutStart = () => {
const [start, setStart] = useState<Date>();
return (
<Box width="400px">
<Box mb={4}>
<Text>Sample Access Request:</Text>
<Text>
<b>Created Date:</b>{' '}
{format(dryRunResponse.created, cfg.dateTimeFormat)}
</Text>
<Text>
<b>Max Duration Date:</b>{' '}
{format(dryRunResponse.maxDuration, cfg.dateTimeFormat)}
</Text>
</Box>
<AssumeStartTime
start={start}
onStartChange={setStart}
accessRequest={dryRunResponse}
reviewing={true}
/>
<AccessDurationReview
assumeStartTime={start}
accessRequest={dryRunResponse}
/>
</Box>
);
};
export const CreatedRequestWithStart = () => {
const [start, setStart] = useState<Date>();
const withStart = {
...dryRunResponse,
assumeStartTime: new Date('2024-02-14T02:51:12.70087Z'),
};
return (
<Box width="400px">
<Box mb={4}>
<Text>Sample Access Request:</Text>
<Text>
<b>Created Date:</b> {format(withStart.created, cfg.dateTimeFormat)}
</Text>
<Text>
<b>Max Duration Date:</b>{' '}
{format(withStart.maxDuration, cfg.dateTimeFormat)}
</Text>
</Box>
<AssumeStartTime
start={start}
onStartChange={setStart}
accessRequest={withStart}
reviewing={true}
/>
<AccessDurationReview
assumeStartTime={start}
accessRequest={dryRunResponse}
/>
</Box>
);
};
@@ -0,0 +1,166 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState } from 'react';
import { render, screen, fireEvent } from 'design/utils/testing';
import { Option } from 'shared/components/Select';
import { AccessRequest } from 'shared/services/accessRequests';
import { dryRunResponse } from '../fixtures';
import { AccessDurationRequest, AccessDurationReview } from '../AccessDuration';
import { AssumeStartTime } from './AssumeStartTime';
test('assume start time, creating mode', () => {
jest.useFakeTimers().setSystemTime(dryRunResponse.created);
render(<AssumeStartTimeComp accessRequest={dryRunResponse} />);
// Init state.
expect(screen.queryByText(/start time/i)).not.toBeInTheDocument();
expect(screen.getByText(/access duration/i)).toBeInTheDocument();
expect(screen.getAllByText(/2 days/i)).toHaveLength(1);
const calendarBtn = screen.getByText(/immediately/i);
fireEvent.click(calendarBtn);
// Selecting a date on the date picker should generate a
// "time" and "access duration" dropdown.
fireEvent.click(screen.getByText(/15/i));
expect(screen.queryByText(/immediately/i)).not.toBeInTheDocument();
expect(screen.queryByTestId('reset-btn')).not.toBeInTheDocument();
expect(screen.getByText(/february 15, 2024/i)).toBeInTheDocument();
expect(screen.getByText(/start time/i)).toBeInTheDocument();
expect(screen.getByText(/3:00 AM/i)).toBeInTheDocument();
expect(screen.getByText('1 day 23 hours 51 minutes')).toBeInTheDocument();
// Selecting a different start "time" should change the
// "access duration" time.
const timeOptBox = screen.getByText(/3:00 AM/i);
fireEvent.keyDown(timeOptBox, { key: 'ArrowDown' });
fireEvent.click(screen.getByText(/11:00 PM/i)); // 10 hour later
expect(screen.getByText(/11:00 PM/i)).toBeInTheDocument();
expect(screen.getByText('1 day 3 hours 51 minutes')).toBeInTheDocument();
// Clicking "immediately" button goes back to default values.
fireEvent.click(screen.getByText(/february 15, 2024/i));
fireEvent.click(screen.getByText(/immediately/i));
expect(screen.getByText(/immediately/i)).toBeInTheDocument();
expect(screen.queryByText(/start time/i)).not.toBeInTheDocument();
expect(screen.getByText(/2 days/i)).toBeInTheDocument();
});
test('assume start time, reviewing mode, with assume start time', () => {
const withStart = {
...dryRunResponse,
assumeStartTime: new Date('2024-02-16T02:51:12.70087Z'),
};
jest.useFakeTimers().setSystemTime(withStart.created);
render(<AssumeStartTimeComp accessRequest={withStart} review={true} />);
// Init state should render the requested assume start date and time.
expect(screen.getByText(/start date/i)).toBeInTheDocument();
expect(screen.getByText(/start time/i)).toBeInTheDocument();
expect(screen.getByText(/requested/i)).toBeInTheDocument();
expect(screen.getByText(/1 day/i)).toBeInTheDocument();
expect(screen.getByText(/access duration:/i)).toBeInTheDocument();
expect(screen.queryByTestId('reset-btn')).not.toBeInTheDocument();
// Changing time should render reset button and update access duration.
const timeOptBox = screen.getByText('2:51 AM (Requested)');
fireEvent.keyDown(timeOptBox, { key: 'ArrowDown' });
fireEvent.click(screen.getByText(/3:00 AM/i));
expect(screen.getByTestId('reset-btn')).toBeInTheDocument();
expect(screen.getByText(/23 hours 51 minutes/i)).toBeInTheDocument();
// Clicking on reset button should go back to the requested time.
fireEvent.click(screen.getByTestId('reset-btn'));
expect(screen.getByText(/1 day/i)).toBeInTheDocument();
expect(screen.getByText('2:51 AM (Requested)')).toBeInTheDocument();
// Clicking on "immediately" button, should change time to "now".
fireEvent.click(screen.getByText(/february 16, 2024/i));
fireEvent.click(screen.getByText(/immediately/i));
expect(screen.getByText(/immediately/i)).toBeInTheDocument();
expect(screen.getByText(/2 days/i)).toBeInTheDocument();
});
test('assume start time, reviewing mode, with NO assume start time', () => {
jest.useFakeTimers().setSystemTime(dryRunResponse.created);
render(<AssumeStartTimeComp accessRequest={dryRunResponse} review={true} />);
// Init state should not render time since it wasn't defined
expect(screen.getByText(/start date/i)).toBeInTheDocument();
expect(screen.queryByText(/start time/i)).not.toBeInTheDocument();
expect(screen.queryByText(/requested/i)).not.toBeInTheDocument();
expect(screen.getByText(/2 days/i)).toBeInTheDocument();
expect(screen.getByText(/access duration:/i)).toBeInTheDocument();
expect(screen.queryByTestId('reset-btn')).not.toBeInTheDocument();
// Clicking on a different date should render time options.
fireEvent.click(screen.getByText(/immediately/i));
fireEvent.click(screen.getByText(/16/i));
expect(screen.getByText(/start time/i)).toBeInTheDocument();
});
const AssumeStartTimeComp = ({
accessRequest,
review = false,
}: {
accessRequest: AccessRequest;
review?: boolean;
}) => {
const [maxDuration, setMaxDuration] = useState<Option<number>>();
const [start, setStart] = useState<Date>();
if (review) {
return (
<>
<AssumeStartTime
start={start}
onStartChange={setStart}
accessRequest={accessRequest}
reviewing={true}
/>
<AccessDurationReview
assumeStartTime={start}
accessRequest={accessRequest}
/>
</>
);
}
return (
<>
<AssumeStartTime
start={start}
onStartChange={setStart}
accessRequest={accessRequest}
/>
<AccessDurationRequest
assumeStartTime={start}
accessRequest={accessRequest}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
/>
</>
);
};
@@ -0,0 +1,237 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState } from 'react';
import styled from 'styled-components';
import { DayPicker } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import { addMonths, format } from 'date-fns';
import { Flex, Box, LabelInput, ButtonIcon } from 'design';
import { Calendar as CalendarIcon, Refresh as RefreshIcon } from 'design/Icon';
import { StyledDateRange } from 'teleport/components/DayPicker/Shared';
import { ButtonSecondary } from 'design/Button';
import Validation from 'shared/components/Validation';
import FieldSelect from 'shared/components/FieldSelect';
import { useRefClickOutside } from 'shared/hooks/useRefClickOutside';
import cfg from 'shared/config';
import { AccessRequest } from 'shared/services/accessRequests';
import { TimeOption } from '../Shared/types';
import {
convertStartToTimeOption,
getMaxAssumableDate,
getTimeOptions,
} from './timeOptions';
export function AssumeStartTime({
start,
onStartChange,
accessRequest,
reviewing = false,
}: {
start: Date;
onStartChange(s?: Date): void;
accessRequest: AccessRequest;
reviewing?: boolean;
}) {
const [wantImmediate, setWantImmediate] = useState(
() => !accessRequest.assumeStartTime
);
const [showDayPicker, setShowDayPicker] = useState(false);
const dayPickerRef = useRefClickOutside<HTMLDivElement>({
open: showDayPicker,
setOpen: setShowDayPicker,
});
function startImmediately() {
setWantImmediate(true);
// Overwrite the requested start time
// with now time.
if (accessRequest.assumeStartTime) {
onStartChange(new Date());
} else {
// This case means the request was already
// requesting to gain access immediately so
// nothing to overwrite here.
onStartChange(null);
}
setShowDayPicker(false);
}
// Updates the start "date" part of a Date, and we pre-select option that is
// closest to one week for the selected date. On every update, it re-calculates
// the time options and duration options available for the selected date.
function updateStartDate(selectedDate: Date) {
setWantImmediate(false);
const updatedTimesOptions = getTimeOptions(
selectedDate,
accessRequest,
reviewing
);
if (!updatedTimesOptions.length) {
// There is no other time options for the current duration.
setShowDayPicker(false);
return;
}
onStartChange(updatedTimesOptions[0].value);
setShowDayPicker(false);
}
// Updates the start "time" part of a Date. On every update, it re-calculates
// the duration options available for the selected time.
function updateStartTime(time: TimeOption) {
setWantImmediate(false);
onStartChange(time?.value);
}
let startDate = accessRequest.created;
if (reviewing) {
startDate = new Date();
}
let startDateText = 'Immediately';
let startTime: TimeOption;
let startTimeOptions: TimeOption[] = [];
const startOrRequestedDate = start || accessRequest.assumeStartTime;
if (!wantImmediate && startOrRequestedDate) {
startDateText = format(startOrRequestedDate, cfg.dateWithFullMonth);
startTime = convertStartToTimeOption(
startOrRequestedDate,
!start && !!accessRequest.assumeStartTime
);
startTimeOptions = getTimeOptions(
startOrRequestedDate,
accessRequest,
reviewing
);
}
// This flag is used to give reviewer the ability to reset start date/time
// to the originally requested date/time.
const showResetDateTime = reviewing && start;
return (
<Validation>
<Flex gap={2} alignItems="end" mb={2}>
<Box css={{ position: 'relative' }} ref={dayPickerRef}>
<LabelInput>Start Date</LabelInput>
<CalendarPicker
onClick={() => {
setShowDayPicker(s => !s);
}}
maxWidth="270px"
minWidth="200px"
>
{startDateText}
<CalendarIcon ml={3} />
</CalendarPicker>
{showDayPicker && (
<StyledDateRange
css={`
position: absolute;
z-index: 10000;
padding: ${p => p.theme.space[1]}px;
height: auto;
.rdp {
--rdp-cell-size: 30px; /* Size of the day cells. */
--rdp-caption-font-size: 14px; /* Font size for the caption labels. */
}
`}
>
<DayPicker
data-testid="day-picker"
onDayClick={updateStartDate}
defaultMonth={startDate}
selected={startOrRequestedDate}
fromMonth={startDate}
// Incase part of 7 days falls to the next month.
// Allows user to select day from next month
// and disables navigating rest of month.
toMonth={addMonths(startDate, 1)}
// Disables before today, and after 7th day.
disabled={[
{
before: startDate,
after: getMaxAssumableDate(accessRequest),
},
]}
footer={
<Flex css={{ justifyContent: 'center' }}>
<ButtonSecondary
mt={2}
onClick={startImmediately}
textTransform="none"
>
Immediately
</ButtonSecondary>
</Flex>
}
/>
</StyledDateRange>
)}
</Box>
{startTime && (
<Box>
<LabelInput>Start Time</LabelInput>
<FieldSelect
mb={0}
width="190px"
isSearchable={true}
options={startTimeOptions}
value={startTime}
onChange={updateStartTime}
/>
</Box>
)}
{showResetDateTime && (
<ButtonIcon
data-testid="reset-btn"
onClick={() => updateStartTime(null)}
title="Reset to requested time"
mb={1}
>
<RefreshIcon size="medium" />
</ButtonIcon>
)}
</Flex>
</Validation>
);
}
const CalendarPicker = styled(Flex)`
height: 40px;
border: 1px solid ${p => p.theme.colors.text.muted};
border-radius: ${p => p.theme.radii[2]}px;
padding: 0 ${p => p.theme.space[2]}px;
align-items: center;
justify-content: space-between;
cursor: pointer;
:hover {
background-color: ${p => p.theme.colors.spotBackground[0]};
border: 1px solid ${p => p.theme.colors.text.slightlyMuted};
}
`;
@@ -0,0 +1,142 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { generateTimeDropdown } from './timeOptions';
test('no limit, 60 min increment, generates every options', () => {
const startDate = new Date('2024-02-16T03:00:08.156944Z');
jest.useFakeTimers().setSystemTime(startDate);
const times = generateTimeDropdown({ startDate }, 60);
// 24 hours total, since it's hourly increment
expect(times).toHaveLength(24);
// Earliest time
expect(times[0].label).toBe('12:00 AM');
expect(times[0].value).toStrictEqual(new Date('2024-02-16T00:00:00.000Z'));
// Middle
expect(times[times.length / 2].label).toBe('12:00 PM');
expect(times[times.length / 2].value).toStrictEqual(
new Date('2024-02-16T12:00:00.000Z')
);
// Last
expect(times[times.length - 1].label).toBe('11:00 PM');
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T23:00:00.000Z')
);
});
test('with min limit, 60 min increment, generates options beginnigng from min', () => {
const startDate = new Date('2024-02-16T20:00:08.156944Z');
jest.useFakeTimers().setSystemTime(startDate);
const times = generateTimeDropdown(
{ startDate, minTimestamp: startDate.getTime() },
60
);
expect(times).toHaveLength(4); // 8PM - 11PM
// Earliest time available is the same time as the start date.
expect(times[0].label).toBe('8:00 PM');
expect(times[0].value).toStrictEqual(new Date('2024-02-16T20:00:00.000Z'));
// Last time of day.
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T23:00:00.000Z')
);
});
test('with both a min and a max limit, 60 min increment, generates options within the min/max range', () => {
const startDate = new Date('2024-02-16T08:00:08.156944Z');
const endDate = new Date('2024-02-16T10:00:08.156944Z');
jest.useFakeTimers().setSystemTime(startDate);
const times = generateTimeDropdown(
{
startDate,
minTimestamp: startDate.getTime(),
maxTimestamp: endDate.getTime(),
},
60
);
expect(times).toHaveLength(3); // 8AM - 10AM
expect(times[0].value).toStrictEqual(new Date('2024-02-16T08:00:00.000Z'));
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T10:00:00.000Z')
);
});
test('with max limit, 60 min increment, generates options ending with max limit', () => {
const startDate = new Date('2024-02-16T08:00:08.156944Z');
jest.useFakeTimers().setSystemTime(startDate);
const times = generateTimeDropdown(
{ startDate, maxTimestamp: startDate.getTime() },
60
);
expect(times).toHaveLength(9); // 12AM - 8AM
// Earliest time available is the same time as the start date.
expect(times[0].label).toBe('12:00 AM');
expect(times[0].value).toStrictEqual(new Date('2024-02-16T00:00:00.000Z'));
// Last time of day.
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T08:00:00.000Z')
);
});
test('no limit, 15 min increment', () => {
const startDate = new Date('2024-02-16T03:00:08.156944Z');
jest.useFakeTimers().setSystemTime(startDate);
const times = generateTimeDropdown({ startDate }, 15);
// 24 hours total * 4, 15 min increments.
expect(times).toHaveLength(24 * 4);
// Test first quarters
expect(times[0].label).toBe('12:00 AM');
expect(times[0].value).toStrictEqual(new Date('2024-02-16T00:00:00.000Z'));
expect(times[1].label).toBe('12:15 AM');
expect(times[1].value).toStrictEqual(new Date('2024-02-16T00:15:00.000Z'));
expect(times[2].label).toBe('12:30 AM');
expect(times[2].value).toStrictEqual(new Date('2024-02-16T00:30:00.000Z'));
expect(times[3].label).toBe('12:45 AM');
expect(times[3].value).toStrictEqual(new Date('2024-02-16T00:45:00.000Z'));
// Middle
expect(times[times.length / 2].label).toBe('12:00 PM');
expect(times[times.length / 2].value).toStrictEqual(
new Date('2024-02-16T12:00:00.000Z')
);
// Last
expect(times[times.length - 1].label).toBe('11:45 PM');
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T23:45:00.000Z')
);
});
@@ -0,0 +1,154 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { AccessRequest } from 'shared/services/accessRequests';
import { getTimeOptions } from './timeOptions';
test('same day limit produces options with a min and a max', () => {
// Same days but with different time.
const created = new Date('2024-02-16T03:00:08.156944Z'); // 02/16
const maxDuration = new Date('2024-02-16T06:45:08.156944Z'); // 02/16
const selectedDate = new Date(created);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const times = getTimeOptions(selectedDate, mockAccessRequest);
// min option should be the same as created hours
expect(times[0].value).toStrictEqual(new Date('2024-02-16T03:00:00.000Z'));
// max option should be the same as max duration date time - 1 hr
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T05:00:00.000Z')
);
});
test('in between day selection produces every options (no limit)', () => {
const created = new Date('2024-02-16T03:00:08.156944Z'); // 02/16
const maxDuration = new Date('2024-02-18T06:45:08.156944Z'); // 02/18
// The in between date, 02/17
const selectedDate = new Date('2024-02-17T03:00:08.156944Z');
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const times = getTimeOptions(selectedDate, mockAccessRequest);
// min option defaults to the earliest time available for the day
expect(times[0].value).toStrictEqual(new Date('2024-02-17T00:00:00.000Z'));
// max option defaults to the latest time available for the day
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-17T23:00:00.000Z')
);
});
test('first day selection produces options with only a min limit', () => {
const created = new Date('2024-02-16T03:00:08.156944Z'); // 02/16
const maxDuration = new Date('2024-02-18T06:45:08.156944Z'); // 02/18
// The first day is the created date.
const selectedDate = new Date(created);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const times = getTimeOptions(selectedDate, mockAccessRequest);
// min option is limited to the created date time
expect(times[0].value).toStrictEqual(new Date('2024-02-16T03:00:00.000Z'));
// max option defaults to the latest time available for the day
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-16T23:00:00.000Z')
);
});
test('last day selection produces option with only a max limit', () => {
const created = new Date('2024-02-16T03:00:08.156944Z'); // 02/16
const maxDuration = new Date('2024-02-18T06:45:08.156944Z'); // 02/18
// The last day is the max duration date
const selectedDate = new Date(maxDuration);
jest.useFakeTimers().setSystemTime(created);
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const times = getTimeOptions(selectedDate, mockAccessRequest);
// min option defaults to the earliest time available for the day
expect(times[0].value).toStrictEqual(new Date('2024-02-18T00:00:00.000Z'));
// max option is limited to the max duration time - 1.
expect(times[times.length - 1].value).toStrictEqual(
new Date('2024-02-18T05:00:00.000Z')
);
});
test('on reviewing mode, start time options should start from current date time', () => {
const current = new Date('2024-02-16T11:00:08.156944Z'); // 11 pm
jest.useFakeTimers().setSystemTime(current);
const created = new Date('2024-02-16T03:00:08.156944Z'); // 3pm
const maxDuration = new Date('2024-02-18T06:45:08.156944Z');
mockAccessRequest.created = created;
mockAccessRequest.maxDuration = maxDuration;
const selectedDate = new Date(created);
const times = getTimeOptions(
selectedDate,
mockAccessRequest,
true /* reviewing */
);
// min option defaults to "current" date & hour.
expect(times[0].value).toStrictEqual(new Date('2024-02-16T11:00:00.000Z'));
});
const mockAccessRequest: AccessRequest = {
id: '31a711f6-f53a-4d61-baae-2c3c8d9a3fd9',
state: 'PENDING',
resolveReason: '',
requestReason: '',
user: 'lisa',
roles: ['@teleport-access-approver'],
created: new Date('2024-02-16T03:00:08.156944Z'),
createdDuration: '',
expires: new Date('2024-02-19T03:00:08.157365Z'),
expiresDuration: '',
maxDuration: new Date('2024-02-19T03:00:08.157365Z'),
maxDurationText: '',
requestTTL: new Date('2024-02-16T04:00:08.157365Z'),
requestTTLDuration: '',
sessionTTL: new Date('2024-02-16T12:11:46.99997Z'),
sessionTTLDuration: '',
reviews: [],
reviewers: [],
thresholdNames: ['default'],
resources: [],
assumeStartTime: null,
};
@@ -0,0 +1,50 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { addDays } from 'date-fns';
import { getMaxAssumableDate, OneWeek } from './timeOptions';
test('getMaxAssumableDate, return the lesser date between max duration and default with 1 hour subtracted', () => {
const created = new Date('2024-02-01T03:00:00.000000Z');
const defaultMaxAssumableDate = addDays(created, OneWeek);
jest.useFakeTimers().setSystemTime(created);
// Return the default when max duration is greater.
let maxDuration = new Date('2024-02-20T03:00:00.000000Z');
let gotDate = getMaxAssumableDate({ created, maxDuration });
expect(gotDate.getTime()).toEqual(
defaultMaxAssumableDate.setHours(defaultMaxAssumableDate.getHours() - 1)
);
// Return max duration when default is greater.
maxDuration = new Date('2024-02-03T03:00:00.000000Z');
gotDate = getMaxAssumableDate({ created, maxDuration });
expect(gotDate.getTime()).toEqual(
maxDuration.setHours(maxDuration.getHours() - 1)
);
});
test('getMaxAssumableDate, returns unmodified date if max duration is less than an hour', () => {
const created = new Date('2024-02-01T03:00:00.000000Z');
const maxDuration = new Date('2024-02-01T03:30:00.000000Z'); // 30 min diff
jest.useFakeTimers().setSystemTime(created);
const gotDate = getMaxAssumableDate({ created, maxDuration });
expect(gotDate).toEqual(maxDuration);
});
@@ -0,0 +1,207 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { addDays, isSameDay } from 'date-fns';
import { dateTimeShortFormat } from 'shared/services/loc/loc';
import { AccessRequest } from 'shared/services/accessRequests';
import { TimeOption } from '../Shared/types';
const OneDayInMinutes = 1440; // 24 hours
export const OneWeek = 7;
// DateTimeLimit defines the earliest a time can start
// and the latest time can end.
type DateTimeLimit = {
minTimestamp?: number;
maxTimestamp?: number;
startDate: Date;
};
/**
* isWithinTimeLimit checks if the current timestamp
* is within the limits of min and max timestamps.
* Returns false if current timestamp is out of min/max range.
*/
function isWithinTimeLimit(limit: DateTimeLimit, currentTimestamp: number) {
if (limit.minTimestamp && limit.maxTimestamp) {
return (
currentTimestamp >= limit.minTimestamp &&
currentTimestamp <= limit.maxTimestamp
);
}
if (limit.minTimestamp) {
return currentTimestamp >= limit.minTimestamp;
}
if (limit.maxTimestamp) {
return currentTimestamp <= limit.maxTimestamp;
}
return true;
}
/**
* generateTimeDropdown generates time options in format 00:00 AM|PM
* Time can start as early as 12:00 AM and can be as late as 11:30 PM
* (if `incrementBy = 30` for example)
*
* The range of options is defined by the limit param.
*/
export function generateTimeDropdown(
limit: DateTimeLimit,
incrementTimeBy = 60 // default to incrementing time by the hour
) {
const times: TimeOption[] = [];
for (let i = 0; i < OneDayInMinutes; i += incrementTimeBy) {
const militaryHrs = Math.floor(i / 60);
const minutes = i % 60;
const currentDate = new Date(limit.startDate);
if (!isWithinTimeLimit(limit, currentDate.setHours(militaryHrs, minutes))) {
continue;
}
const date = new Date(limit.startDate);
date.setHours(militaryHrs, minutes, 0 /* sec */, 0 /* ms */);
times.push({
label: dateTimeShortFormat(date),
value: date,
});
}
return times;
}
export function convertStartToTimeOption(
startDate: Date,
requested = false
): TimeOption {
if (!startDate) {
return null;
}
const time = {
label: dateTimeShortFormat(startDate),
value: startDate,
};
if (requested) {
time.label += ' (Requested)';
}
return time;
}
/**
* Calculates selectable time options based on the day user has selected.
* There are limits to the earliest time option selectable and to the
* latest time option selectable based on the time options (`created` and
* `maxDuration`) returned from the initial dry run access request.
*/
export function getTimeOptions(
selectedDate: Date,
accessRequest: AccessRequest,
reviewing = false
): TimeOption[] {
const maxAssumableDate = getMaxAssumableDate(accessRequest);
let minDate = accessRequest.created;
if (reviewing) {
// Give reviewer the time options starting from now
// otherwise we can render time options in the past
// (reviewing a request after some time has passed).
minDate = new Date();
}
const maxAssumableDuration = maxAssumableDate.getTime();
// Today was the only day available to select.
// This means there will be both a start limit (earliest time selectable)
// and a end limit (latest time selectable).
if (
isSameDay(selectedDate, minDate) &&
isSameDay(selectedDate, maxAssumableDate)
) {
return generateTimeDropdown({
minTimestamp: minDate.getTime(),
maxTimestamp: maxAssumableDuration,
startDate: selectedDate,
});
}
// User selected the first day among other selectable days.
// This means there is only a start limit (earliest time selectable)
// and end is only limited to the last time available for the day (23:59)
else if (isSameDay(selectedDate, minDate)) {
return generateTimeDropdown({
minTimestamp: minDate.getTime(),
startDate: selectedDate,
});
}
// User selected the last day among other selectable days.
// This means there is only a end limit (latest time selectable) and
// start is only limited to the earliest time available for the day (00:00)
else if (isSameDay(selectedDate, maxAssumableDate)) {
return generateTimeDropdown({
maxTimestamp: maxAssumableDuration,
startDate: selectedDate,
});
}
// User selected in between the first and last date selectable, so
// any time options are selectable (00:00 - 23:59)
else {
return generateTimeDropdown({ startDate: selectedDate });
}
}
/**
* Selects the lesser value between maxDuration
* and default max assume start time (one week).
*
* Note: maxDuration backend limit can be up to 2 weeks.
*/
export function getMaxAssumableDate({
created,
maxDuration,
}: {
created: Date;
maxDuration: Date;
}) {
let maxAssumableDate = addDays(created, OneWeek);
// Select the lesser value.
if (maxAssumableDate.getTime() > maxDuration.getTime()) {
maxAssumableDate = new Date(maxDuration);
}
// Subtract an hour off the max duration so we don't display
// options that is too near the max duration (worst case the
// request has like a few seconds before it expires)
const modifiedMaxAssumableDate = new Date(maxAssumableDate);
modifiedMaxAssumableDate.setHours(maxAssumableDate.getHours() - 1);
// Handles an edge case where the max duration is less than an hour.
if (modifiedMaxAssumableDate.getTime() < created.getTime()) {
return maxAssumableDate;
}
return modifiedMaxAssumableDate;
}
@@ -0,0 +1,131 @@
/**
* Copyright 2024 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 { Flex, Text, ButtonIcon, Box, LabelInput } from 'design';
import * as Icon from 'design/Icon';
import Select, { Option } from 'shared/components/Select';
import { ToolTipInfo } from 'shared/components/ToolTip';
import { AccessRequest } from 'shared/services/accessRequests';
import { getFormattedDurationTxt } from '../../Shared/utils';
import { getDurationOptionIndexClosestToOneWeek } from '../../AccessDuration/durationOptions';
import { getPendingRequestDurationOptions } from './utils';
export function AdditionalOptions({
selectedMaxDurationTimestamp,
setRequestTTL,
requestTTL,
dryRunResponse,
maxDuration,
}: {
selectedMaxDurationTimestamp: number;
setRequestTTL(o: Option<number>): void;
requestTTL: Option<number>;
dryRunResponse: AccessRequest;
maxDuration: Option<number>;
}) {
// Options for extending pending TTL.
const [requestTTLDurationOptions, setRequestTTLDurationOptions] = useState<
Option<number>[]
>([]);
const [expanded, setExpanded] = useState(false);
const ArrowIcon = expanded ? Icon.ChevronDown : Icon.ChevronRight;
// With every max duration change, recalculate the pending TTL
// options to never succeed the max duration.
useEffect(() => {
const options = getPendingRequestDurationOptions(
dryRunResponse.created,
maxDuration.value
);
setRequestTTLDurationOptions(options);
if (options.length >= 1) {
const index = getDurationOptionIndexClosestToOneWeek(
options,
dryRunResponse.created
);
setRequestTTL(options[index]);
}
}, [maxDuration]);
return (
<>
<Flex
borderBottom={1}
mt={1}
mb={2}
pb={2}
justifyContent="space-between"
alignItems="center"
height="34px"
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Text mr={2} fontSize={1}>
Additional Options
</Text>
<ButtonIcon
onClick={() => setExpanded(e => !e)}
data-testid="additional-info-btn"
>
<ArrowIcon size="medium" />
</ButtonIcon>
</Flex>
{expanded && (
<Box data-testid="reviewers">
{requestTTLDurationOptions.length > 0 && (
<LabelInput typography="body2" color="text.slightlyMuted" mb={3}>
<Flex alignItems="center">
<Text mr={1}>Request expires if not reviewed in</Text>
<ToolTipInfo>
The request TTL which is the amount of time this request will
be in the PENDING state before it expires.
</ToolTipInfo>
</Flex>
<Select
options={requestTTLDurationOptions}
onChange={(option: Option<number>) => setRequestTTL(option)}
value={requestTTL}
/>
</LabelInput>
)}
<LabelInput typography="body2" color="text.slightlyMuted">
<Flex alignItems="center">
<Text mr={1}>Access Request Lifetime</Text>
<ToolTipInfo>
The max duration of an access request, starting from its
creation, until it expires.
</ToolTipInfo>
</Flex>
<Text>
{getFormattedDurationTxt({
start: dryRunResponse.created,
end: new Date(selectedMaxDurationTimestamp),
})}
</Text>
</LabelInput>
</Box>
)}
</>
);
}
@@ -0,0 +1,37 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { render } from 'design/utils/testing';
import { Loaded, Failed, Success } from './RequestCheckout.story';
test('loaded state', async () => {
const { container } = render(<Loaded />);
expect(container).toMatchSnapshot();
});
test('failed state', async () => {
const { container } = render(<Failed />);
expect(container).toMatchSnapshot();
});
test('success state', () => {
const { container } = render(<Success />);
expect(container).toMatchSnapshot();
});
@@ -0,0 +1,218 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import { MemoryRouter, Link } from 'react-router-dom';
import { Box, ButtonPrimary, ButtonText } from 'design';
import { Option } from 'shared/components/Select';
import { dryRunResponse } from '../../fixtures';
import { RequestCheckout, RequestCheckoutProps } from './RequestCheckout';
export default {
title: 'Shared/AccessRequests/Checkout',
};
function SuccessActionComponent({ reset, onClose }) {
return (
<Box textAlign="center">
<ButtonPrimary
as={Link}
mt={5}
mb={3}
width="100%"
size="large"
to={'/web/requests'}
>
Back to Listings
</ButtonPrimary>
<ButtonText
onClick={() => {
reset();
onClose();
}}
>
Make Another Request
</ButtonText>
</Box>
);
}
export const Loaded = () => {
const [selectedReviewers, setSelectedReviewers] = useState(
props.selectedReviewers
);
const [maxDuration, setMaxDuration] = useState<Option<number>>();
const [requestTTL, setRequestTTL] = useState<Option<number>>();
return (
<RequestCheckout
{...props}
selectedReviewers={selectedReviewers}
setSelectedReviewers={setSelectedReviewers}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
requestTTL={requestTTL}
setRequestTTL={setRequestTTL}
/>
);
};
export const Empty = () => {
const [selectedReviewers, setSelectedReviewers] = useState([]);
const [maxDuration, setMaxDuration] = useState<Option<number>>();
const [requestTTL, setRequestTTL] = useState<Option<number>>();
return (
<RequestCheckout
{...props}
data={[]}
selectedReviewers={selectedReviewers}
setSelectedReviewers={setSelectedReviewers}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
requestTTL={requestTTL}
setRequestTTL={setRequestTTL}
/>
);
};
export const Failed = () => (
<RequestCheckout
{...props}
requireReason={false}
createAttempt={{
status: 'failed',
statusText: 'some error message',
}}
SuccessComponent={SuccessActionComponent}
selectedReviewers={[]}
/>
);
export const LoadedResourceRequest = () => {
const [selectedReviewers, setSelectedReviewers] = useState(
props.selectedReviewers
);
const [selectedResourceRequestRoles, setSelectedResourceRequestRoles] =
useState(props.resourceRequestRoles);
return (
<RequestCheckout
{...props}
isResourceRequest={true}
fetchResourceRequestRolesAttempt={{ status: 'success' }}
selectedResourceRequestRoles={selectedResourceRequestRoles}
setSelectedResourceRequestRoles={setSelectedResourceRequestRoles}
selectedReviewers={selectedReviewers}
setSelectedReviewers={setSelectedReviewers}
/>
);
};
export const ProcessingResourceRequest = () => (
<RequestCheckout
{...props}
isResourceRequest={true}
fetchResourceRequestRolesAttempt={{ status: 'processing' }}
/>
);
export const FailedResourceRequest = () => (
<RequestCheckout
{...props}
isResourceRequest={true}
fetchResourceRequestRolesAttempt={{
status: 'failed',
statusText: 'An error has occurred',
}}
/>
);
export const Success = () => (
<MemoryRouter initialEntries={['']}>
<RequestCheckout
{...props}
requireReason={false}
createAttempt={{ status: 'success' }}
SuccessComponent={SuccessActionComponent}
/>
</MemoryRouter>
);
const props: RequestCheckoutProps = {
createAttempt: { status: '' },
fetchResourceRequestRolesAttempt: { status: '' },
isResourceRequest: false,
requireReason: true,
reviewers: ['bob', 'cat', 'george washington'],
selectedReviewers: [
{ value: 'bob', label: 'bob', isSelected: true },
{ value: 'cat', label: 'cat', isSelected: true },
{
value: 'george washington',
label: 'george washington',
isSelected: true,
},
],
setSelectedReviewers: () => null,
createRequest: () => null,
data: [
{
kind: 'app',
name: 'app-name',
id: 'app-name',
},
{
kind: 'db',
name: 'app-name',
id: 'app-name',
},
{
kind: 'kube_cluster',
name: 'kube-name',
id: 'app-name',
},
{
kind: 'user_group',
name: 'user-group-name',
id: 'app-name',
},
{
kind: 'windows_desktop',
name: 'desktop-name',
id: 'app-name',
},
],
clearAttempt: () => null,
onClose: () => null,
toggleResource: () => null,
reset: () => null,
transitionState: 'entered',
numRequestedResources: 4,
resourceRequestRoles: ['admin', 'access', 'developer'],
selectedResourceRequestRoles: ['admin', 'access'],
setSelectedResourceRequestRoles: () => null,
fetchStatus: 'loaded',
maxDuration: { value: 0, label: '12 hours' },
setMaxDuration: () => null,
requestTTL: { value: 0, label: '1 hour' },
setRequestTTL: () => null,
dryRunResponse,
};
@@ -0,0 +1,175 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState } from 'react';
import { render, screen, userEvent, fireEvent } from 'design/utils/testing';
import { Option } from 'shared/components/Select';
import { dryRunResponse } from '../../fixtures';
import { ReviewerOption } from './types';
import {
RequestCheckout as RequestCheckoutComp,
RequestCheckoutProps,
} from './RequestCheckout';
test('start with no suggested reviewers', async () => {
render(<RequestCheckout />);
// Test init renders no reviewers.
let reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(0);
// Add a reviewer
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.type(
screen.getByText(/type or select a name/i),
'llama{enter}'
);
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(1);
expect(reviewers.childNodes[0]).toHaveTextContent('llama');
// Remove by clicking on x button.
fireEvent.click(reviewers.childNodes[0].lastChild);
reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(0);
});
test('start with suggested reviewers', async () => {
render(<RequestCheckout reviewers={['llama']} />);
// Test init renders reviewers.
let reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(1);
expect(reviewers.childNodes[0]).toHaveTextContent('llama');
// Add another reviewer.
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
await userEvent.type(
screen.getByText(/type or select a name/i),
'alpaca{enter}'
);
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(2);
expect(reviewers.childNodes[0]).toHaveTextContent('llama');
expect(reviewers.childNodes[1]).toHaveTextContent('alpaca');
// Remove a suggested reviewer by typing the name.
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
await userEvent.type(
screen.getByText(/type or select a name/i),
'llama{enter}'
);
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(1);
expect(reviewers.childNodes[0]).toHaveTextContent('alpaca');
// Suggested reviewer should still be rendered in the dropdown.
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
await userEvent.click(screen.getByTitle(/llama/i));
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
reviewers = screen.getByTestId('reviewers');
expect(reviewers.childNodes).toHaveLength(2);
expect(reviewers.childNodes[0]).toHaveTextContent('alpaca');
expect(reviewers.childNodes[1]).toHaveTextContent('llama');
});
test('assume start time + additional info access request lifetime', () => {
jest.useFakeTimers().setSystemTime(dryRunResponse.created);
render(<RequestCheckout />);
const infoBtn = screen.getByTestId('additional-info-btn');
// Init state.
expect(screen.queryByText(/start time/i)).not.toBeInTheDocument();
expect(screen.getByText(/access duration/i)).toBeInTheDocument();
expect(screen.getAllByText(/2 days/i)).toHaveLength(1);
const calendarBtn = screen.getByText(/immediately/i);
fireEvent.click(calendarBtn);
// Expand the additional info box where the access lifetime
// gets displayed.
fireEvent.click(infoBtn);
expect(screen.getByText(/Access Request Lifetime/i)).toBeInTheDocument();
expect(screen.getAllByText(/2 days/i)).toHaveLength(2);
// Changing the "access duration" to a shorter time
// should reduce the "access lifetime".
fireEvent.keyDown(screen.getAllByText(/2 days/i)[0], { key: 'ArrowDown' });
fireEvent.click(screen.getByText(/1 day/i));
expect(screen.getAllByText(/1 day/i)).toHaveLength(2);
});
const RequestCheckout = ({ reviewers = [] }: { reviewers?: string[] }) => {
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerOption[]>(
() => reviewers.map(r => ({ label: r, value: r, isSelected: true }))
);
const [maxDuration, setMaxDuration] = useState<Option<number>>();
return (
<div>
<RequestCheckoutComp
{...props}
reviewers={reviewers}
selectedReviewers={selectedReviewers}
setSelectedReviewers={setSelectedReviewers}
isResourceRequest={true}
fetchResourceRequestRolesAttempt={{ status: 'success' }}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
/>
</div>
);
};
const props: RequestCheckoutProps = {
createAttempt: { status: '' },
fetchResourceRequestRolesAttempt: { status: '' },
isResourceRequest: false,
requireReason: true,
reviewers: [],
selectedReviewers: [],
setSelectedReviewers: () => null,
createRequest: () => null,
data: [],
clearAttempt: () => null,
onClose: () => null,
toggleResource: () => null,
reset: () => null,
transitionState: 'entered',
numRequestedResources: 4,
resourceRequestRoles: ['admin', 'access', 'developer'],
selectedResourceRequestRoles: ['admin', 'access'],
setSelectedResourceRequestRoles: () => null,
fetchStatus: 'loaded',
maxDuration: { value: 0, label: '12 hours' },
setMaxDuration: () => null,
requestTTL: { value: 0, label: '1 hour' },
setRequestTTL: () => null,
dryRunResponse,
};
@@ -0,0 +1,690 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useRef, useState } from 'react';
import styled from 'styled-components';
import {
Alert,
Box,
ButtonIcon,
ButtonPrimary,
Flex,
Image,
Indicator,
LabelInput,
Text,
} from 'design';
import {
ArrowBack,
ChevronDown,
ChevronRight,
Trash,
Warning,
} from 'design/Icon';
import Table, { Cell } from 'design/DataTable';
import { CheckboxInput, CheckboxWrapper } from 'design/Checkbox';
import { Danger } from 'design/Alert';
import Validation, { useRule, Validator } from 'shared/components/Validation';
import { Attempt } from 'shared/hooks/useAttemptNext';
import { pluralize } from 'shared/utils/text';
import { Option } from 'shared/components/Select';
import { CreateRequest } from '../../Shared/types';
import { AssumeStartTime } from '../../AssumeStartTime/AssumeStartTime';
import { AccessDurationRequest } from '../../AccessDuration';
import { ReviewerOption } from './types';
import shieldCheck from './shield-check.png';
import { SelectReviewers } from './SelectReviewers';
import { AdditionalOptions } from './AdditionalOptions';
import type { TransitionStatus } from 'react-transition-group';
import type { AccessRequest } from 'shared/services/accessRequests';
import type { ResourceKind } from '../resource';
export function RequestCheckout({
toggleResource,
onClose,
transitionState,
reset,
data,
createAttempt,
appsGrantedByUserGroup = [],
userGroupFetchAttempt,
fetchResourceRequestRolesAttempt,
resourceRequestRoles,
createRequest,
clearAttempt,
reviewers,
selectedReviewers,
setSelectedReviewers,
SuccessComponent,
requireReason,
numRequestedResources,
isResourceRequest,
selectedResourceRequestRoles,
setSelectedResourceRequestRoles,
fetchStatus,
maxDuration,
setMaxDuration,
requestTTL,
setRequestTTL,
dryRunResponse,
}: RequestCheckoutProps) {
// Specifies the start date/time a requestor requested for.
const [start, setStart] = useState<Date>();
const [reason, setReason] = useState('');
const ref = useRef<HTMLDivElement>();
const isInvalidRoleSelection =
resourceRequestRoles.length > 0 &&
isResourceRequest &&
selectedResourceRequestRoles.length < 1;
const submitBtnDisabled =
data.length === 0 ||
createAttempt.status === 'processing' ||
isInvalidRoleSelection ||
fetchResourceRequestRolesAttempt.status === 'failed' ||
fetchResourceRequestRolesAttempt.status === 'processing';
function updateReason(reason: string) {
setReason(reason);
}
function handleOnSubmit(validator: Validator) {
if (!validator.validate()) {
return;
}
createRequest({
reason,
suggestedReviewers: selectedReviewers.map(r => r.value),
maxDuration: maxDuration ? new Date(maxDuration.value) : null,
requestTTL: requestTTL ? new Date(requestTTL.value) : null,
start: start,
});
}
// 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.
React.useEffect(() => {
function applyOverflowAutoStyle(e: TransitionEvent) {
if (e.propertyName === 'right') {
ref.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') {
ref.current.style.overflow = `hidden`;
}
}
window.addEventListener('transitionend', applyOverflowAutoStyle);
return () => {
window.removeEventListener('transitionend', applyOverflowAutoStyle);
window.removeEventListener('transitionstart', applyOverflowHiddenStyle);
};
}, []);
return (
<div
ref={ref}
css={`
position: absolute;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
overflow: hidden;
`}
>
<Dimmer className={transitionState} />
<SidePanel state={transitionState} className={transitionState}>
{fetchResourceRequestRolesAttempt.status === 'failed' && (
<Alert
kind="danger"
children={fetchResourceRequestRolesAttempt.statusText}
/>
)}
{fetchStatus === 'loading' && (
<Box mt={5} textAlign="center">
<Indicator />
</Box>
)}
{fetchStatus === 'loaded' && (
<div>
{createAttempt.status === 'success' ? (
<Box>
<Box mt={2} mb={7} textAlign="center">
<Text typography="h4" color="text.main" bold>
Resources Requested Successfully
</Text>
<Text typography="subtitle1" color="text.slightlyMuted">
You've successfully requested {numRequestedResources}{' '}
{pluralize(numRequestedResources, 'resource')}
</Text>
</Box>
<Flex justifyContent="center" mb={3}>
<Image src={shieldCheck} width="250px" height="179px" />
</Flex>
</Box>
) : (
<Flex mb={3} alignItems="center">
<ArrowBack
size="large"
mr={3}
onClick={onClose}
style={{ cursor: 'pointer' }}
/>
<Box>
<Text typography="h4" color="text.main" bold>
{data.length} {pluralize(data.length, 'Resource')} Selected
</Text>
</Box>
</Flex>
)}
{createAttempt.status === 'success' ? (
<SuccessComponent onClose={onClose} reset={reset} />
) : (
<>
{createAttempt.status === 'failed' && (
<Alert kind="danger" children={createAttempt.statusText} />
)}
<StyledTable
data={data}
columns={[
{
key: 'kind',
headerText: 'Resource Kind',
},
{
key: 'name',
headerText: 'Resource Name',
},
{
altKey: 'delete-btn',
render: resource => (
<Cell align="right">
<Trash
size="small"
borderRadius={2}
p={2}
onClick={() => {
clearAttempt();
toggleResource(
resource.kind,
resource.id,
resource.name
);
}}
disabled={createAttempt.status === 'processing'}
css={`
cursor: pointer;
background-color: ${({ theme }) =>
theme.colors.buttons.trashButton.default};
border-radius: 2px;
:hover {
background-color: ${({ theme }) =>
theme.colors.buttons.trashButton.hover};
}
`}
/>
</Cell>
),
},
]}
emptyText="No resources are selected"
/>
{userGroupFetchAttempt?.status === 'processing' && (
<Flex mt={4} alignItems="center" justifyContent="center">
<Indicator size="small" />
</Flex>
)}
{userGroupFetchAttempt?.status === 'failed' && (
<Danger mt={4}>{userGroupFetchAttempt.statusText}</Danger>
)}
{userGroupFetchAttempt?.status === 'success' &&
appsGrantedByUserGroup.length > 0 && (
<AppsGrantedAccess apps={appsGrantedByUserGroup} />
)}
{isResourceRequest && (
<ResourceRequestRoles
roles={resourceRequestRoles}
selectedRoles={selectedResourceRequestRoles}
setSelectedRoles={setSelectedResourceRequestRoles}
fetchAttempt={fetchResourceRequestRolesAttempt}
/>
)}
<Box mt={6} mb={1}>
<SelectReviewers
reviewers={reviewers}
selectedReviewers={selectedReviewers}
setSelectedReviewers={setSelectedReviewers}
/>
</Box>
<Validation>
{({ validator }) => (
<Flex mt={6} flexDirection="column" gap={1}>
{dryRunResponse && (
<Box mb={1}>
<AssumeStartTime
start={start}
onStartChange={setStart}
accessRequest={dryRunResponse}
/>
<AccessDurationRequest
assumeStartTime={start}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
accessRequest={dryRunResponse}
/>
</Box>
)}
<TextBox
reason={reason}
updateReason={updateReason}
requireReason={requireReason}
/>
{dryRunResponse && maxDuration && (
<AdditionalOptions
selectedMaxDurationTimestamp={maxDuration?.value}
maxDuration={maxDuration}
setRequestTTL={setRequestTTL}
requestTTL={requestTTL}
dryRunResponse={dryRunResponse}
/>
)}
<Box
py={4}
css={`
position: sticky;
bottom: 0;
background: ${({ theme }) =>
theme.colors.levels.sunken};
`}
>
<ButtonPrimary
width="100%"
size="large"
onClick={() => handleOnSubmit(validator)}
disabled={submitBtnDisabled}
>
Submit Request
</ButtonPrimary>
</Box>
</Flex>
)}
</Validation>
</>
)}
</div>
)}
</SidePanel>
</div>
);
}
function AppsGrantedAccess({ apps }: { apps: string[] }) {
const [expanded, setExpanded] = useState(true);
const ArrowIcon = expanded ? ChevronDown : ChevronRight;
// if its a single app, just show the app they are getting access to
if (apps.length === 1) {
return (
<Box mt={4} width="100%">
<Text mb={0}>
Grants access to the{' '}
<Text style={{ display: 'inline' }} color="brand">
{apps[0]}
</Text>{' '}
app
</Text>
</Box>
);
}
return (
<Box mt={7} width="100%">
<Box style={{ cursor: 'pointer' }}>
<Flex
justifyContent="space-between"
width="100%"
borderBottom={1}
onClick={() => setExpanded(!expanded)}
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Flex flexDirection="column" width="100%">
<LabelInput mb={0} style={{ cursor: 'pointer' }}>
{`Grants access to ${apps.length} apps`}
</LabelInput>
</Flex>
{apps.length > 0 && <ArrowIcon size="medium" />}
</Flex>
</Box>
{expanded && (
<Box mt={2}>
{apps.map(app => {
return <Text>{app}</Text>;
})}
</Box>
)}
</Box>
);
}
function ResourceRequestRoles({
roles,
selectedRoles,
setSelectedRoles,
fetchAttempt,
}: {
roles: string[];
selectedRoles: string[];
setSelectedRoles: (roles: string[]) => void;
fetchAttempt: Attempt;
}) {
const [expanded, setExpanded] = useState(true);
const ArrowIcon = expanded ? ChevronDown : ChevronRight;
function onInputChange(
roleName: string,
e: React.ChangeEvent<HTMLInputElement>
) {
if (e.target.checked) {
return setSelectedRoles([...selectedRoles, roleName]);
}
setSelectedRoles(selectedRoles.filter(role => role !== roleName));
}
return (
<Box mt={7} width="100%">
<Box style={{ cursor: 'pointer' }}>
<Flex
justifyContent="space-between"
width="100%"
borderBottom={1}
onClick={() => setExpanded(!expanded)}
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Flex flexDirection="column" width="100%">
<LabelInput mb={0} style={{ cursor: 'pointer' }}>
Roles
</LabelInput>
<Text typography="subtitle2" mb={2}>
{selectedRoles.length} role{selectedRoles.length !== 1 ? 's' : ''}{' '}
selected
</Text>
</Flex>
{fetchAttempt.status === 'processing' ? (
<Flex
mt={3}
mr={1}
height="100%"
alignItems="center"
justifyContent="center"
>
<Indicator size="medium" />
</Flex>
) : (
<Flex
mt={2}
height="100%"
alignItems="center"
justifyContent="center"
>
<ButtonIcon>
<ArrowIcon size="medium" />
</ButtonIcon>
</Flex>
)}
</Flex>
</Box>
{fetchAttempt.status === 'success' && expanded && (
<Box mt={2}>
{roles.map((roleName, index) => {
const id = `${roleName}${index}`;
return (
<CheckboxWrapper
key={index}
css={`
width: 100%;
cursor: pointer;
background: ${({ theme }) => theme.colors.levels.surface};
&:hover {
border-color: ${({ theme }) =>
theme.colors.levels.elevated};
}
`}
as="label"
htmlFor={id}
>
<CheckboxInput
type="checkbox"
name={roleName}
id={id}
onChange={e => {
onInputChange(roleName, e);
}}
checked={selectedRoles.includes(roleName)}
/>
{roleName}
</CheckboxWrapper>
);
})}
{selectedRoles.length < roles.length && (
<Flex
alignItems="center"
justifyContent="space-between"
mt={3}
py={2}
px={3}
borderRadius={3}
css={`
width: 100%;
background: ${({ theme }) => theme.colors.levels.surface};
`}
>
<Warning mr={3} size="medium" color="warning.main" />
<Text typography="subtitle2">
Modifying this role set may disable access to some of the above
resources. Use with caution.
</Text>
</Flex>
)}
</Box>
)}
</Box>
);
}
function TextBox({
reason,
updateReason,
requireReason,
}: {
reason: string;
updateReason(reason: string): void;
requireReason: boolean;
}) {
const { valid, message } = useRule(requireText(reason, requireReason));
const hasError = !valid;
const labelText = hasError ? message : 'Request Reason';
const optionalText = requireReason ? '' : ' (optional)';
const placeholder = `Describe your request...${optionalText}`;
return (
<LabelInput hasError={hasError}>
{labelText}
<Box
as="textarea"
height="80px"
width="100%"
borderRadius={2}
p={2}
color="text.main"
border={hasError ? '2px solid' : '1px solid'}
borderColor={hasError ? 'error.main' : 'text.muted'}
placeholder={placeholder}
value={reason}
onChange={e => updateReason(e.target.value)}
css={`
outline: none;
background: transparent;
::placeholder {
color: ${({ theme }) => theme.colors.text.muted};
}
&:hover,
&:focus,
&:active {
border: 1px solid ${props => props.theme.colors.text.slightlyMuted};
}
`}
/>
</LabelInput>
);
}
const requireText = (value: string, requireReason: boolean) => () => {
if (requireReason && (!value || value.trim().length === 0)) {
return {
valid: false,
message: 'Reason Required',
};
}
return { valid: true };
};
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;
export type RequestCheckoutProps = {
onClose(): void;
toggleResource: (
kind: ResourceKind,
resourceId: string,
resourceName?: string
) => void;
appsGrantedByUserGroup?: string[];
userGroupFetchAttempt?: Attempt;
reset: () => void;
SuccessComponent?: (params: SuccessComponentParams) => JSX.Element;
transitionState: TransitionStatus;
isResourceRequest: boolean;
requireReason: boolean;
selectedReviewers: ReviewerOption[];
data: { kind: ResourceKind; name: string; id: string }[];
setRequestTTL: (value: Option<number>) => void;
createRequest: (req: CreateRequest) => void;
fetchStatus: 'loading' | 'loaded';
fetchResourceRequestRolesAttempt: Attempt;
requestTTL: Option<number>;
resourceRequestRoles: string[];
reviewers: string[];
setSelectedReviewers: (value: ReviewerOption[]) => void;
setMaxDuration: (value: Option<number>) => void;
clearAttempt: () => void;
createAttempt: Attempt;
setSelectedResourceRequestRoles: (value: string[]) => void;
numRequestedResources: number;
selectedResourceRequestRoles: string[];
dryRunResponse: AccessRequest;
maxDuration: Option<number>;
};
type SuccessComponentParams = {
reset: () => void;
onClose: () => void;
};
@@ -0,0 +1,391 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useState, useRef } from 'react';
import { components } from 'react-select';
import ReactSelectCreatable from 'react-select/creatable';
import styled from 'styled-components';
import { ButtonBorder, Box, Text, Flex, ButtonIcon } from 'design';
import * as Icon from 'design/Icon';
import { ReviewerOption } from './types';
export function SelectReviewers({
reviewers,
selectedReviewers,
setSelectedReviewers,
}) {
const selectWrapperRef = useRef(null);
const reactSelectRef = useRef(null);
const [editReviewers, setEditReviewers] = useState(false);
const [suggestedReviewers, setSuggestedReviewers] = useState<
ReviewerOption[]
>(
// Initially, all suggested reviewers are selected for the requestor.
() => reviewers.map(r => ({ value: r, label: r, isDisabled: true }))
);
React.useEffect(() => {
// When editing reviewers, auto focus on input box.
if (editReviewers) {
reactSelectRef.current.focus();
}
// When editing reviewers, clicking outside box closes editor.
function handleOnClick(e) {
if (!editReviewers || e.target.closest('.react-select__option')) return;
if (!selectWrapperRef.current?.contains(e.target)) {
setEditReviewers(false);
}
}
window.addEventListener('click', handleOnClick);
return () => {
window.removeEventListener('click', handleOnClick);
};
}, [editReviewers]);
const reviewerOptions = [
{
label: '',
options: selectedReviewers,
},
{
label: 'Suggested Reviewers',
options: suggestedReviewers,
},
];
// formatGroupLabel customizes react-select labels.
const formatGroupLabel = data => {
if (!data.label) {
return null;
}
return <SelectGroupLabel>{data.label}</SelectGroupLabel>;
};
// Option customizes how react-select options appear.
const Option = props => {
if (props.data.isDisabled) {
return null;
}
if (props.data.isSelected) {
return (
<components.Option {...props} className="react-select__selected">
<Flex alignItems="center" justifyContent="space-between">
<Flex alignItems="center" width="210px">
<Icon.CircleCheck size="medium" color="success.main" mr={2} />
<Text title={props.data.value}>{props.data.value}</Text>
</Flex>
<Icon.Cross size="small" />
</Flex>
</components.Option>
);
}
return (
<components.Option {...props}>
<Text mx={4} title={props.data.value}>
{props.data.label}
</Text>
</components.Option>
);
};
function handleOnChange(values: ReviewerOption[]) {
const updateSelectedReviewers = values.map(r => ({
value: r.value,
label: r.label,
// isSelected flag is used to customize style.
isSelected: true,
}));
const updateSuggestedReviewers = suggestedReviewers.map(r => {
if (values.find(t => t.value === r.value)) {
// isDisabled flag is used to not render this name in suggested list.
r.isDisabled = true;
} else {
r.isDisabled = false;
}
return r;
});
setSelectedReviewers(updateSelectedReviewers);
setSuggestedReviewers(updateSuggestedReviewers);
}
function toggleEditReviewers() {
setEditReviewers(!editReviewers);
}
return (
<Box style={{ position: 'relative' }}>
<SelectWrapper
ref={selectWrapperRef}
style={{ display: editReviewers ? '' : 'none' }}
>
<ReactSelectCreatable
className="react-select-container"
classNamePrefix="react-select"
isClearable={false}
isMulti={true}
isSearchable={true}
menuIsOpen={true}
controlShouldRenderValue={false}
hideSelectedOptions={false}
placeholder="Type or select a name"
value={selectedReviewers}
options={reviewerOptions}
onChange={handleOnChange}
formatGroupLabel={formatGroupLabel}
components={{ Option }}
noOptionsMessage={() => null}
ref={reactSelectRef}
/>
</SelectWrapper>
<Reviewers
reviewers={selectedReviewers}
editReviewers={editReviewers}
toggleEditReviewers={toggleEditReviewers}
updateReviewers={handleOnChange}
/>
</Box>
);
}
function Reviewers({
reviewers,
editReviewers,
toggleEditReviewers,
updateReviewers,
}: {
reviewers: ReviewerOption[];
editReviewers: boolean;
toggleEditReviewers(): void;
updateReviewers(o: ReviewerOption[]): void;
}) {
const [expanded, setExpanded] = useState(true);
const ArrowIcon = expanded ? Icon.ChevronDown : Icon.ChevronRight;
const $reviewers = reviewers.map((reviewer, index) => {
return (
<Flex
border={1}
borderColor="levels.surface"
borderRadius={1}
px={3}
py={2}
alignItems="center"
justifyContent="space-between"
key={index}
css={`
background: ${props => props.theme.colors.spotBackground[0]};
`}
>
<Text
typography="body2"
bold
style={{ whiteSpace: 'nowrap', maxWidth: '200px' }}
title={reviewer.value}
>
{reviewer.value}
</Text>
<ButtonIcon
size={0}
title="Remove reviewer"
onClick={() =>
updateReviewers(reviewers.filter(r => r.value != reviewer.value))
}
>
<Icon.Cross size={16} />
</ButtonIcon>
</Flex>
);
});
let btnTxt = 'Add';
if (reviewers.length > 0) {
btnTxt = 'Edit';
}
if (editReviewers) {
btnTxt = 'Done';
}
return (
<>
<Flex
borderBottom={1}
mb={2}
pb={2}
justifyContent="space-between"
alignItems="center"
height="34px"
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Flex>
<Text mr={2} fontSize={1}>
Reviewers (optional)
</Text>
<ButtonBorder
onClick={e => {
// By stopping propagation,
// we prevent this event from being interpreted as an outside click.
e.stopPropagation();
toggleEditReviewers();
}}
size="small"
width="50px"
>
{btnTxt}
</ButtonBorder>
</Flex>
{reviewers.length > 0 && (
<ButtonIcon onClick={() => setExpanded(e => !e)}>
<ArrowIcon size="medium" />
</ButtonIcon>
)}
</Flex>
{expanded && <Box data-testid="reviewers">{$reviewers}</Box>}
</>
);
}
const SelectWrapper = styled(Box)`
width: 260px;
height: 150px;
background-color: #ffffff;
color: #000000;
border-radius: 3px;
position: absolute;
z-index: 1;
top: 40px;
.react-select__group,
.react-select__group-heading,
.react-select__menu-list {
padding: 0;
margin: 0;
}
.react-select__menu-list {
margin-top: 10px;
}
// Removes auto focus on first option
.react-select__option--is-focused {
background-color: inherit;
&:hover {
background-color: #deebff;
}
}
.react-select-container {
width: 300px;
box-sizing: border-box;
border: none;
display: block;
font-size: 16px;
outline: none;
width: 100%;
background-color: #ffffff;
margin-top: 16px;
border-radius: 4px;
}
.react-select__menu {
box-shadow: none;
}
.react-select__control {
border-radius: 30px;
background-color: #f0f2f4;
margin: 0px 16px 10px 16px;
&:hover {
cursor: pointer;
}
}
.react-select__control--is-focused {
border-color: transparent;
box-shadow: none;
}
.react-select__placeholder {
font-size: 14px;
}
.react-select__option {
white-space: nowrap;
padding: 9px 16px;
border-top: 1px solid #eaeaea;
font-weight: bold;
font-size: 14px;
&:hover {
cursor: pointer;
&:last-child {
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
}
}
.icon-circlecheck {
color: transparent;
margin-right: 10px;
}
}
.react-select__option--is-selected {
background-color: inherit;
color: inherit;
}
.react-select__indicators {
display: none;
}
.react-select__selected {
.icon-circlecheck {
color: ${props => props.theme.colors.success.main};
}
.icon-cross {
color: ${props => props.theme.colors.bgTerminal};
display: none;
}
&:hover .icon-cross {
display: block;
}
}
`;
const SelectGroupLabel = styled(Box)`
width: 100%;
background-color: #efefef;
color: #324148;
text-transform: none;
padding: 3px 15px;
`;
@@ -0,0 +1,23 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { RequestCheckout } from './RequestCheckout';
export type { RequestCheckoutProps } from './RequestCheckout';
export * from './utils';
export type { ReviewerOption } from './types';
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,24 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { Option } from 'shared/components/Select';
export type ReviewerOption = Option & {
isDisabled?: boolean;
isSelected?: boolean;
};
@@ -0,0 +1,114 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { addDays, addHours } from 'date-fns';
import { Option } from 'shared/components/Select';
import {
getPendingRequestDurationOptions,
presetDays,
presetHours,
} from './utils';
test('duration is less than 1 hour', () => {
const created = new Date('2024-02-16T03:00:00.000000Z');
const maxDuration = new Date('2024-02-16T03:45:00.000000Z');
jest.useFakeTimers().setSystemTime(created);
const opts = getPendingRequestDurationOptions(created, maxDuration.getTime());
// Only one option, the max duration.
expect(opts).toHaveLength(1);
expect(opts[0].value).toBe(maxDuration.getTime());
expect(opts[0].label).toBe('45 minutes');
});
test('duration is a mix of some preset hours and the max duration', () => {
const created = new Date('2024-02-16T03:00:00.000000Z');
const maxDuration = new Date('2024-02-16T05:45:00.000000Z');
jest.useFakeTimers().setSystemTime(created);
const opts = getPendingRequestDurationOptions(created, maxDuration.getTime());
// Only one option, the max duration.
expect(opts).toHaveLength(3);
expect(opts[0].value).toBe(addHours(created, 1).getTime());
expect(opts[0].label).toBe('1 hour');
expect(opts[1].value).toBe(addHours(created, 2).getTime());
expect(opts[1].label).toBe('2 hours');
expect(opts[2].value).toBe(maxDuration.getTime());
expect(opts[2].label).toBe('2 hours 45 minutes');
});
test('defining all preset hours', () => {
expect(presetHours).toHaveLength(8);
const created = new Date('2024-02-16T03:00:00.000000Z');
const maxDuration = new Date('2024-02-16T21:00:00.000000Z');
jest.useFakeTimers().setSystemTime(created);
const opts = getPendingRequestDurationOptions(created, maxDuration.getTime());
expect(opts).toHaveLength(presetHours.length);
testPresetHours(opts, created);
});
test('defining all preset days + preset hours + maxest', () => {
expect(presetHours).toHaveLength(8);
expect(presetDays).toHaveLength(7);
const created = new Date('2024-02-16T03:00:00.000000Z');
const maxDuration = new Date('2024-02-27T03:30:00.000000Z');
jest.useFakeTimers().setSystemTime(created);
const opts = getPendingRequestDurationOptions(created, maxDuration.getTime());
expect(opts).toHaveLength(presetHours.length + presetDays.length);
testPresetHours(opts, created);
for (let i = 0; i < presetDays.length; i += 1) {
const optionIndex = i + presetHours.length;
const dayTxt = i ? 'days' : 'day';
if (i == presetDays.length - 1) {
break;
}
expect(opts[optionIndex].label).toBe(`${presetDays[i]} ${dayTxt}`);
expect(opts[optionIndex].value).toBe(
addDays(created, presetDays[i]).getTime()
);
}
// Test maxest duration.
expect(opts[opts.length - 1].value).toBe(addDays(created, 7).getTime());
expect(opts[opts.length - 1].label).toBe('7 days');
});
function testPresetHours(opts: Option<number>[], createdDate: Date) {
// one preset hour
for (let i = 0; i < presetHours.length; i += 1) {
const addedDate = addHours(createdDate, presetHours[i]);
const hourTxt = i ? 'hours' : 'hour';
expect(opts[i].label).toBe(`${presetHours[i]} ${hourTxt}`);
expect(opts[i].value).toBe(addedDate.getTime());
}
}
@@ -0,0 +1,123 @@
/**
* Copyright 2024 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 { addHours, addDays, isAfter } from 'date-fns';
import { Option } from 'shared/components/Select';
import { getFormattedDurationTxt } from '../../Shared/utils';
// Preset hour options for the time option dropdowns.
export const presetHours = [1, 2, 3, 4, 6, 8, 12, 18];
// Preset day options for the time option dropdowns.
export const presetDays = [1, 2, 3, 4, 5, 6, 7];
const HourInMs = 60 * 60 * 1000;
export function getPendingRequestDurationOptions(
accessRequestCreated: Date,
maxDuration: number
): Option<number>[] {
const createdDate = new Date(accessRequestCreated);
// Backend limits max pending date to be 1 week.
// MaxDuration can get greater than 1 week.
// Pick the smaller of the two.
let maxPendingDate = new Date(maxDuration);
const maxPendingDay = presetDays.length - 1;
const possiblySmallerMaxPendingDate = addDays(
createdDate,
presetDays[maxPendingDay]
);
if (maxPendingDate.getTime() > possiblySmallerMaxPendingDate.getTime()) {
maxPendingDate = possiblySmallerMaxPendingDate;
}
const createdTimestamp = createdDate.getTime();
const pendingTimestamp = maxPendingDate.getTime();
let durationOpts: Option<number>[] = [];
const totalHoursDiff = (pendingTimestamp - createdTimestamp) / HourInMs;
// If there is less than an hour available, return
// it as max duration as the only option.
if (totalHoursDiff <= 1) {
durationOpts.push({
value: pendingTimestamp,
label: getFormattedDurationTxt({
start: createdDate,
end: maxPendingDate,
}),
});
return durationOpts;
}
// Add preset hour options up to maximum allowed.
for (const hour of presetHours) {
const updatedDateTime = addHours(createdDate, hour);
if (isAfter(updatedDateTime, maxPendingDate)) {
break;
}
durationOpts.push({
value: updatedDateTime.getTime(),
label: getFormattedDurationTxt({
start: createdDate,
end: updatedDateTime,
}),
});
}
// Add preset days up to maximum allowed.
if (totalHoursDiff >= 24) {
for (const day of presetDays) {
const updatedEndDate = addDays(createdDate, day);
if (isAfter(updatedEndDate, maxPendingDate)) {
break;
}
durationOpts.push({
value: updatedEndDate.getTime(),
label: getFormattedDurationTxt({
start: createdDate,
end: updatedEndDate,
}),
});
}
}
const lastDurationOption = durationOpts[durationOpts.length - 1];
if (pendingTimestamp > lastDurationOption.value) {
durationOpts.push({
value: pendingTimestamp,
label: getFormattedDurationTxt({
start: createdDate,
end: maxPendingDate,
}),
});
}
return durationOpts;
}
/**
* Backend expects the maxDuration field to be set to some value
* which on dry run's will get overwritten to whatever the backend
* defualt max maxDuration is. Leaving maxDuration field undefined
* for dry run's, for some reason gets respected.
*/
export function getDryRunMaxDuration() {
const sevenDaysInMs = 1000 * 60 * 60 * 24 * 7;
return new Date(Date.now() + sevenDaysInMs);
}
@@ -0,0 +1,291 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { components } from 'react-select';
import { Flex, Text, ButtonBorder, ButtonPrimary } from 'design';
import { ClickableLabelCell, Cell } from 'design/DataTable';
import { App } from 'teleport/services/apps';
import Select, {
Option as BaseOption,
CustomSelectComponentProps,
} from 'shared/components/Select';
import { StyledSelect as BaseStyledSelect } from 'shared/components/Select/Select';
import { ToolTipInfo } from 'shared/components/ToolTip';
import { ResourceMap, ResourceKind } from '../resource';
import { ListProps, StyledTable } from './ResourceList';
type Option = BaseOption & {
isSelected?: boolean;
};
export function Apps(props: ListProps & { apps: App[] }) {
const {
apps = [],
addedResources,
customSort,
onLabelClick,
addOrRemoveResource,
} = props;
return (
<StyledTable
data={apps}
columns={[
{
key: 'name',
headerText: 'Name',
isSortable: true,
},
{
key: 'description',
headerText: 'Description',
isSortable: true,
},
{
key: 'addrWithProtocol',
headerText: 'Address',
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels }) => (
<ClickableLabelCell labels={labels} onClick={onLabelClick} />
),
},
{
altKey: 'action-btn',
render: agent => (
<ActionCell
agent={agent}
addedResources={addedResources}
addOrRemoveResource={addOrRemoveResource}
/>
),
},
]}
emptyText="No Results Found"
customSort={customSort}
disableFilter
/>
);
}
const OptionComponent = (
props: CustomSelectComponentProps<
{ toggleUserGroup(groupId: string, groupDescription: string): void },
Option
>
) => {
const { toggleUserGroup } = props.selectProps.customProps;
return (
<components.Option {...props} className="react-select__selected">
<Flex
alignItems="center"
onClick={() => toggleUserGroup(props.value, props.label)}
py="8px"
px="12px"
>
<input
type="checkbox"
checked={props.isSelected}
readOnly
name={props.value}
id={props.value}
/>{' '}
<Text ml={1}>{props.label}</Text>
</Flex>
</components.Option>
);
};
function ActionCell({
agent,
addedResources,
addOrRemoveResource,
}: {
agent: App;
addedResources: ResourceMap;
addOrRemoveResource: (
kind: ResourceKind,
resourceId: string,
resourceName?: string
) => void;
}) {
const [userGroupOptions] = useState<Option[]>(() => {
return agent.userGroups.map(ug => {
return { label: ug.description, value: ug.name };
});
});
const [selectedGroups, setSelectedGroups] = useState<Option[]>([]);
useEffect(() => {
if (userGroupOptions.length === 0) {
return;
}
// Applications can refer to the same user group id.
// When user selects an option from one row, we need
// to update selected groups for all other rows.
const updatedSelectedGroups = userGroupOptions.flatMap(o => {
if (addedResources.user_group[o.value]) {
return { ...o, isSelected: true };
}
return []; // skip this option
});
setSelectedGroups(updatedSelectedGroups);
// A user can only select an app OR user groups.
// If a user selected a user group from one row,
// that is also applicable to this row,
// remove app from selection.
if (addedResources.app[agent.name] && updatedSelectedGroups.length > 0) {
addOrRemoveResource('app', agent.name);
}
}, [addedResources]);
function handleSelectedGroups(o: Option[]) {
// Deselect the app if a user is selecting from a list of groups
// for the first time.
if (selectedGroups.length === 0 && addedResources.app[agent.name]) {
addOrRemoveResource('app', agent.name); // remove app from selection.
}
setSelectedGroups(o);
}
function toggleUserGroup(id: string, description = '') {
addOrRemoveResource('user_group', id, description);
}
function toggleApp() {
addOrRemoveResource('app', agent.name, agent.friendlyName);
}
const isAppAdded = Boolean(addedResources.app[agent.name]);
const hasSelectedGroups = selectedGroups.length > 0;
if (!isAppAdded && !hasSelectedGroups) {
return (
<Cell align="right">
<ButtonBorder width="134px" size="small" onClick={toggleApp}>
+ Add to request
</ButtonBorder>
</Cell>
);
}
if (isAppAdded && agent.userGroups.length === 0) {
return (
<Cell align="right">
<ButtonPrimary width="134px" size="small" onClick={toggleApp}>
Remove
</ButtonPrimary>
</Cell>
);
}
// Remove button is only shown when user has not added user
// groups yet, but has the option to do so
const showRemoveButton = isAppAdded && !hasSelectedGroups;
return (
<Cell align="right">
{showRemoveButton && (
<ButtonPrimary width="134px" size="small" onClick={toggleApp}>
Remove
</ButtonPrimary>
)}
<Flex alignItems="center" justifyContent="end">
<ToolTipInfo muteIconColor={true}>
This application {agent.name} can be alternatively requested by
members of user groups. You can alternatively select user groups
instead to access this application.
</ToolTipInfo>
<StyledSelect className={hasSelectedGroups ? 'hasSelectedGroups' : ''}>
<Select
placeholder={
hasSelectedGroups
? `${selectedGroups.length} User Groups Added`
: 'Alternatively Select User Groups'
}
value={selectedGroups}
options={userGroupOptions}
isSearchable={false}
isClearable={false}
isMulti={true}
hideSelectedOptions={false}
controlShouldRenderValue={false}
onChange={handleSelectedGroups}
components={{
Option: OptionComponent,
}}
customProps={{ toggleUserGroup }}
/>
</StyledSelect>
</Flex>
</Cell>
);
}
const StyledSelect = styled(BaseStyledSelect)`
margin-left: 8px;
input[type='checkbox'] {
cursor: pointer;
}
.react-select__control {
font-size: 12px;
width: 225px;
height: 26px;
min-height: 24px;
border: 2px solid ${p => p.theme.colors.buttons.secondary.default};
}
.react-select__menu {
font-size: 12px;
width: 275px;
right: 0;
}
.react-select__option {
padding: 0;
}
.react-select__value-container {
position: static;
}
.react-select__dropdown-indicator {
padding-top: 0px;
}
&.hasSelectedGroups {
.react-select-container {
background: ${p => p.theme.colors.buttons.primary.default};
}
.react-select__placeholder,
.react-select__dropdown-indicator {
color: ${p => p.theme.colors.buttons.primary.text};
}
}
`;
@@ -0,0 +1,73 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ClickableLabelCell } from 'design/DataTable';
import { Database } from 'teleport/services/databases';
import { ListProps, StyledTable, renderActionCell } from './ResourceList';
export function Databases(props: ListProps & { databases: Database[] }) {
const {
databases = [],
onLabelClick,
addedResources,
addOrRemoveResource,
customSort,
} = props;
return (
<StyledTable
data={databases}
columns={[
{
key: 'name',
headerText: 'Name',
isSortable: true,
},
{
key: 'description',
headerText: 'Description',
isSortable: true,
},
{
key: 'type',
headerText: 'Type',
isSortable: true,
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels }) => (
<ClickableLabelCell labels={labels} onClick={onLabelClick} />
),
},
{
altKey: 'action-btn',
render: agent =>
renderActionCell(Boolean(addedResources.db[agent.name]), () =>
addOrRemoveResource('db', agent.name)
),
},
]}
emptyText="No Results Found"
customSort={customSort}
disableFilter
/>
);
}
@@ -0,0 +1,68 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ClickableLabelCell } from 'design/DataTable';
import { Desktop } from 'teleport/services/desktops';
import { ListProps, StyledTable, renderActionCell } from './ResourceList';
export function Desktops(props: ListProps & { desktops: Desktop[] }) {
const {
desktops = [],
addedResources,
customSort,
onLabelClick,
addOrRemoveResource,
} = props;
return (
<StyledTable
data={desktops}
columns={[
{
key: 'addr',
headerText: 'Address',
},
{
key: 'name',
headerText: 'Name',
isSortable: true,
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels }) => (
<ClickableLabelCell labels={labels} onClick={onLabelClick} />
),
},
{
altKey: 'action-btn',
render: agent =>
renderActionCell(
Boolean(addedResources.windows_desktop[agent.name]),
() => addOrRemoveResource('windows_desktop', agent.name)
),
},
]}
emptyText="No Results Found"
customSort={customSort}
disableFilter
/>
);
}
@@ -0,0 +1,64 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ClickableLabelCell } from 'design/DataTable';
import { Kube } from 'teleport/services/kube';
import { ListProps, StyledTable, renderActionCell } from './ResourceList';
export function Kubes(props: ListProps & { kubes: Kube[] }) {
const {
kubes = [],
addedResources,
customSort,
onLabelClick,
addOrRemoveResource,
} = props;
return (
<StyledTable
data={kubes}
columns={[
{
key: 'name',
headerText: 'Name',
isSortable: true,
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels }) => (
<ClickableLabelCell labels={labels} onClick={onLabelClick} />
),
},
{
altKey: 'action-btn',
render: agent =>
renderActionCell(
Boolean(addedResources.kube_cluster[agent.name]),
() => addOrRemoveResource('kube_cluster', agent.name)
),
},
]}
emptyText="No Results Found"
customSort={customSort}
disableFilter
/>
);
}
@@ -0,0 +1,83 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Cell, ClickableLabelCell } from 'design/DataTable';
import { Node } from 'teleport/services/nodes';
import { ListProps, StyledTable, renderActionCell } from './ResourceList';
export function Nodes(props: ListProps & { nodes: Node[] }) {
const {
nodes = [],
addedResources,
customSort,
onLabelClick,
addOrRemoveResource,
} = props;
return (
<StyledTable
data={nodes}
columns={[
{
key: 'hostname',
headerText: 'Hostname',
isSortable: true,
},
{
key: 'addr',
headerText: 'Address',
render: renderAddressCell,
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels }) => (
<ClickableLabelCell labels={labels} onClick={onLabelClick} />
),
},
{
altKey: 'action-btn',
render: agent =>
renderActionCell(Boolean(addedResources.node[agent.id]), () =>
addOrRemoveResource('node', agent.id, agent.hostname)
),
},
]}
emptyText="No Results Found"
customSort={customSort}
disableFilter
/>
);
}
export const renderAddressCell = ({ addr, tunnel }: Node) => (
<Cell>{tunnel ? renderTunnel() : addr}</Cell>
);
function renderTunnel() {
return (
<span
style={{ cursor: 'default', whiteSpace: 'nowrap' }}
title="This node is connected to cluster through reverse tunnel"
>
← tunnel
</span>
);
}
@@ -0,0 +1,65 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { render } from 'design/utils/testing';
import {
Apps,
Databases,
Desktops,
Kubes,
Nodes,
Roles,
UserGroups,
} from './ResourceList.story';
test('render Apps', async () => {
const { container } = render(<Apps />);
expect(container).toMatchSnapshot();
});
test('render Databases', async () => {
const { container } = render(<Databases />);
expect(container).toMatchSnapshot();
});
test('render Desktops', async () => {
const { container } = render(<Desktops />);
expect(container).toMatchSnapshot();
});
test('render Kubes', async () => {
const { container } = render(<Kubes />);
expect(container).toMatchSnapshot();
});
test('render Nodes', async () => {
const { container } = render(<Nodes />);
expect(container).toMatchSnapshot();
});
test('render Roles', async () => {
const { container } = render(<Roles />);
expect(container).toMatchSnapshot();
});
test('render UserGroups', async () => {
const { container } = render(<UserGroups />);
expect(container).toMatchSnapshot();
});
@@ -0,0 +1,169 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Desktop } from 'teleport/services/desktops';
import { Database } from 'teleport/services/databases';
import { App } from 'teleport/services/apps';
import { Kube } from 'teleport/services/kube';
import { Node } from 'teleport/services/nodes';
import { UserGroup } from 'teleport/services/userGroups';
import { getEmptyResourceState } from '../resource';
import { ResourceList, ResourceListProps } from './ResourceList';
export default {
title: 'Shared/AccessRequests/ResourceList',
};
export const Apps = () => <ResourceList {...props} agents={apps} />;
export const Databases = () => (
<ResourceList {...props} agents={dbs} selectedResource="db" />
);
export const Desktops = () => (
<ResourceList
{...props}
agents={desktops}
selectedResource="windows_desktop"
/>
);
export const Kubes = () => (
<ResourceList {...props} agents={kubes} selectedResource="kube_cluster" />
);
export const Nodes = () => (
<ResourceList {...props} agents={nodes} selectedResource="node" />
);
export const Roles = () => (
<ResourceList
{...props}
requestableRoles={['role1', 'role2']}
selectedResource="role"
/>
);
export const UserGroups = () => (
<ResourceList {...props} agents={userGroups} selectedResource="user_group" />
);
const props: ResourceListProps = {
agents: [],
selectedResource: 'app',
customSort: { dir: 'ASC', fieldName: '', onSort: () => null },
onLabelClick: () => null,
addedResources: getEmptyResourceState(),
addOrRemoveResource: () => null,
requestableRoles: [],
disableRows: false,
};
const apps: App[] = [
{
name: 'aws-console-1',
kind: 'app',
uri: 'https://console.aws.amazon.com/ec2/v2/home',
publicAddr: 'awsconsole-1.teleport-proxy.com',
addrWithProtocol: 'https://awsconsole-1.teleport-proxy.com',
labels: [
{
name: 'aws_account_id',
value: 'A1234',
},
],
description: 'This is an AWS Console app',
awsConsole: true,
samlApp: false,
awsRoles: [],
clusterId: 'one',
fqdn: 'awsconsole-1.com',
id: 'one-aws-console-1-awsconsole-1.teleport-proxy.com',
launchUrl: '',
userGroups: [],
},
];
const nodes: Node[] = [
{
tunnel: false,
kind: 'node',
subKind: 'teleport',
sshLogins: ['dev', 'root'],
id: '104',
clusterId: 'one',
hostname: 'fujedu',
addr: '172.10.1.20:3022',
labels: [
{
name: 'cluster',
value: 'one',
},
],
},
];
const dbs: Database[] = [
{
name: 'aurora',
kind: 'db',
description: 'PostgreSQL 11.6: AWS Aurora ',
hostname: 'aurora-hostname',
type: 'RDS PostgreSQL',
protocol: 'postgres',
labels: [{ name: 'cluster', value: 'root' }],
},
];
const desktops: Desktop[] = [
{
os: 'windows',
kind: 'windows_desktop',
name: 'bb8411a4-ba50-537c-89b3-226a00447bc6',
addr: 'host.com',
labels: [{ name: 'foo', value: 'bar' }],
logins: ['Administrator'],
},
];
const kubes: Kube[] = [
{
name: 'tele.logicoma.dev-prod',
kind: 'kube_cluster',
labels: [{ name: 'env', value: 'prod' }],
},
];
const userGroups: UserGroup[] = [
{
kind: 'user_group',
name: 'group id 1',
description: 'user group',
labels: [{ name: 'env', value: 'prod' }],
},
{
kind: 'user_group',
name: 'group id 2',
description: 'admin group',
labels: [{ name: 'env', value: 'dev' }],
},
];
@@ -0,0 +1,127 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import styled from 'styled-components';
import { ButtonBorder, ButtonPrimary, Box } from 'design';
import Table, { Cell } from 'design/DataTable';
import { Desktop } from 'teleport/services/desktops';
import { Database } from 'teleport/services/databases';
import { App } from 'teleport/services/apps';
import { Kube } from 'teleport/services/kube';
import { Node } from 'teleport/services/nodes';
import { UserGroup } from 'teleport/services/userGroups';
import { CustomSort } from 'design/DataTable/types';
import { ResourceLabel, UnifiedResource } from 'teleport/services/agents';
import { ResourceMap, ResourceKind } from '../resource';
import { Apps } from './Apps';
import { Databases } from './Databases';
import { Nodes } from './Nodes';
import { Desktops } from './Desktops';
import { Kubes } from './Kubes';
import { Roles } from './Roles';
import { UserGroups } from './UserGroups';
export function ResourceList(props: ResourceListProps) {
const {
agents,
disableRows,
selectedResource,
requestableRoles,
...listProps
} = props;
return (
<Wrapper className={disableRows ? 'disabled' : ''}>
{selectedResource === 'app' && (
<Apps apps={agents as App[]} {...listProps} />
)}
{selectedResource === 'db' && (
<Databases databases={agents as Database[]} {...listProps} />
)}
{selectedResource === 'node' && (
<Nodes nodes={agents as Node[]} {...listProps} />
)}
{selectedResource === 'windows_desktop' && (
<Desktops desktops={agents as Desktop[]} {...listProps} />
)}
{selectedResource === 'kube_cluster' && (
<Kubes kubes={agents as Kube[]} {...listProps} />
)}
{selectedResource === 'role' && (
<Roles roles={requestableRoles} {...listProps} />
)}
{selectedResource === 'user_group' && (
<UserGroups userGroups={agents as UserGroup[]} {...listProps} />
)}
</Wrapper>
);
}
export const StyledTable = styled(Table)`
& > tbody > tr > td {
vertical-align: middle;
}
` as typeof Table;
const Wrapper = styled(Box)`
&.disabled {
pointer-events: none;
opacity: 0.5;
}
`;
export function renderActionCell(
isAgentAdded: boolean,
toggleAgent: () => void
) {
return (
<Cell align="right">
{isAgentAdded ? (
<ButtonPrimary width="134px" size="small" onClick={toggleAgent}>
Remove
</ButtonPrimary>
) : (
<ButtonBorder width="134px" size="small" onClick={toggleAgent}>
+ Add to request
</ButtonBorder>
)}
</Cell>
);
}
export type ListProps = {
customSort: CustomSort;
onLabelClick: (label: ResourceLabel) => void;
addedResources: ResourceMap;
addOrRemoveResource: (
kind: ResourceKind,
resourceId: string,
resourceName?: string
) => void;
requestableRoles?: string[];
};
export type ResourceListProps = {
agents: UnifiedResource[];
selectedResource: ResourceKind;
// disableRows disable clicking on any buttons (when fetching).
disableRows: boolean;
} & ListProps;
@@ -0,0 +1,48 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ListProps, StyledTable, renderActionCell } from './ResourceList';
export function Roles(props: ListProps & { roles: string[] }) {
const { roles = [], addedResources, addOrRemoveResource } = props;
return (
<StyledTable
data={roles.map(role => ({ role }))}
pagination={{ pagerPosition: 'top', pageSize: 10 }}
isSearchable={true}
columns={[
{
key: 'role',
headerText: 'Role Name',
isSortable: true,
},
{
altKey: 'action-btn',
render: ({ role }) =>
renderActionCell(Boolean(addedResources.role[role]), () =>
addOrRemoveResource('role', role)
),
},
]}
emptyText="No Requestable Roles Found"
/>
);
}
@@ -0,0 +1,75 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ClickableLabelCell } from 'design/DataTable';
import { UserGroup } from 'teleport/services/userGroups';
import { ListProps, StyledTable, renderActionCell } from './ResourceList';
export function UserGroups(props: ListProps & { userGroups: UserGroup[] }) {
const {
userGroups = [],
addedResources,
customSort,
onLabelClick,
addOrRemoveResource,
} = props;
return (
<StyledTable
data={userGroups}
columns={[
{
key: 'name',
headerText: 'Name',
isSortable: true,
render: ({ friendlyName, name }) => <td>{friendlyName || name}</td>,
},
{
key: 'description',
headerText: 'Description',
isSortable: true,
},
{
key: 'labels',
headerText: 'Labels',
render: ({ labels }) => (
<ClickableLabelCell labels={labels} onClick={onLabelClick} />
),
},
{
altKey: 'action-btn',
render: agent =>
renderActionCell(
Boolean(addedResources.user_group[agent.name]),
() =>
addOrRemoveResource(
'user_group',
agent.name,
agent.friendlyName
)
),
},
]}
emptyText="No Results Found"
customSort={customSort}
disableFilter
/>
);
}
@@ -0,0 +1,19 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { ResourceList } from './ResourceList';
@@ -0,0 +1,22 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export * from './RequestCheckout';
export * from './ResourceList';
export type { ResourceMap, ResourceKind } from './resource';
export { getEmptyResourceState } from './resource';
@@ -0,0 +1,38 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { ResourceIdKind } from 'teleport/services/agents';
/** Available request kinds for resource-based and role-based access requests. */
export type ResourceKind = ResourceIdKind | 'role' | 'resource';
export type ResourceMap = {
[K in ResourceIdKind | 'role']: Record<string, string>;
};
export function getEmptyResourceState(): ResourceMap {
return {
node: {},
db: {},
app: {},
kube_cluster: {},
user_group: {},
windows_desktop: {},
role: {},
};
}
@@ -0,0 +1,90 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { Text, LabelState, Flex } from 'design';
import { Cell } from 'design/DataTable';
import { ArrowFatLinesUp } from 'design/Icon';
import { AccessRequest } from 'shared/services/accessRequests';
export const renderUserCell = ({ user }: AccessRequest) => {
return (
<Cell
style={{
maxWidth: '100px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={user}
>
{user}
</Cell>
);
};
export const renderIdCell = ({ id }: AccessRequest) => {
return (
<Cell
style={{
maxWidth: '100px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={id}
>
{id.slice(-5)}
</Cell>
);
};
export const renderStatusCell = ({ state }: AccessRequest) => {
if (state === 'PROMOTED') {
return (
<Cell>
<Flex alignItems="center">
<ArrowFatLinesUp size={17} color="success.main" mr={1} ml="-3px" />
<Text typography="body2">{state}</Text>
</Flex>
</Cell>
);
}
let kind = 'warning';
if (state === 'APPROVED') {
kind = 'success';
} else if (state === 'DENIED') {
kind = 'danger';
}
return (
<Cell>
<Flex alignItems="center">
<LabelState
kind={kind}
mr={2}
width="10px"
p={0}
style={{ minHeight: '10px' }}
/>
<Text typography="body2">{state}</Text>
</Flex>
</Cell>
);
};
@@ -0,0 +1,19 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { renderIdCell, renderStatusCell, renderUserCell } from './RequestList';
@@ -0,0 +1,43 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { render, screen } from 'design/utils/testing';
import { Loaded, Failed, Processing, Approved } from './RequestDelete.story';
test('loaded state', () => {
render(<Loaded />);
expect(screen.getByTestId('Modal')).toMatchSnapshot();
});
test('failed state', () => {
render(<Failed />);
expect(screen.getByTestId('Modal')).toMatchSnapshot();
});
test('processing state', () => {
render(<Processing />);
expect(screen.getByTestId('Modal')).toMatchSnapshot();
});
test('approved role escalation', () => {
render(<Approved />);
expect(screen.getByTestId('Modal')).toMatchSnapshot();
});
@@ -0,0 +1,79 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import {
makeEmptyAttempt,
makeProcessingAttempt,
makeErrorAttempt,
} from 'shared/hooks/useAsync';
import { RequestDelete } from './RequestDelete';
export default {
title: 'Shared/AccessRequests/RequestDelete',
};
export const Loaded = () => {
return (
<RequestDelete
{...props}
requestState="PENDING"
deleteRequestAttempt={makeEmptyAttempt()}
/>
);
};
export const Processing = () => {
return (
<RequestDelete
{...props}
requestState="PENDING"
deleteRequestAttempt={makeProcessingAttempt()}
/>
);
};
export const Failed = () => {
return (
<RequestDelete
{...props}
requestState="PENDING"
deleteRequestAttempt={makeErrorAttempt(new Error('server error'))}
/>
);
};
export const Approved = () => {
return (
<RequestDelete
{...props}
deleteRequestAttempt={makeEmptyAttempt()}
requestState="APPROVED"
/>
);
};
const props = {
requestId: '5ee98d44-de9d-5103-a7cd-072b1ff76253',
user: 'admin',
roles: ['dba'],
onDelete: () => null,
onClose: () => null,
};
@@ -0,0 +1,108 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ButtonWarning, ButtonSecondary, Flex, Text, Alert } from 'design';
import TextSelectCopy from 'teleport/components/TextSelectCopy';
import Dialog, {
DialogHeader,
DialogTitle,
DialogContent,
DialogFooter,
} from 'design/Dialog';
import { Attempt } from 'shared/hooks/useAsync';
import RolesRequested from '../RolesRequested';
import type { RequestState } from 'shared/services/accessRequests';
export interface RequestDeleteProps {
requestId: string;
requestState: RequestState;
user: string;
roles: string[];
onClose(): void;
deleteRequestAttempt: Attempt<void>;
onDelete(): void;
}
export function RequestDelete({
deleteRequestAttempt,
user,
roles,
requestId,
requestState,
onClose,
onDelete,
}: RequestDeleteProps) {
return (
<Dialog
dialogCss={() => ({ maxWidth: '550px', width: '100%' })}
disableEscapeKeyDown={false}
onClose={onClose}
open={true}
>
<DialogHeader>
<DialogTitle>Delete Request?</DialogTitle>
</DialogHeader>
<DialogContent>
{deleteRequestAttempt.status === 'error' && (
<Alert kind="danger" children={deleteRequestAttempt.statusText} />
)}
<Flex flexWrap="wrap" gap={1} alignItems="baseline">
<Text typography="body2">
You are about to delete a request from <strong>{user}</strong> for
the following roles:
</Text>
<RolesRequested roles={roles} />
</Flex>
{requestState === 'APPROVED' && (
<>
<Text mt={3} mb={2} typography="body2">
Since this access request has already been approved, deleting the
request now will NOT remove the user's access to these roles. If
you would like to lock the user's access to the requested roles,
you can run:
</Text>
<TextSelectCopy
mt={2}
text={`tctl lock --access-request ${requestId}`}
/>
</>
)}
</DialogContent>
<DialogFooter>
<ButtonWarning
mr="3"
disabled={deleteRequestAttempt.status === 'processing'}
onClick={onDelete}
>
Delete Request
</ButtonWarning>
<ButtonSecondary
disabled={deleteRequestAttempt.status === 'processing'}
onClick={onClose}
>
Cancel
</ButtonSecondary>
</DialogFooter>
</Dialog>
);
}
@@ -0,0 +1,20 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { RequestDelete } from './RequestDelete';
export type { RequestDeleteProps } from './RequestDelete';
@@ -0,0 +1,33 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { render } from 'design/utils/testing';
import { Loaded, Failed } from './RequestReview.story';
test('loaded state', () => {
const { container } = render(<Loaded />);
expect(container).toMatchSnapshot();
});
test('failed state', () => {
const { container } = render(<Failed />);
expect(container).toMatchSnapshot();
});
@@ -0,0 +1,69 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import {
makeSuccessAttempt,
makeEmptyAttempt,
makeProcessingAttempt,
makeErrorAttempt,
} from 'shared/hooks/useAsync';
import { requestRolePending } from '../../../fixtures';
import RequestReview, { RequestReviewProps } from './RequestReview';
export default {
title: 'Shared/AccessRequests/RequestReview',
decorators: [
Story => (
<div style={{ backgroundColor: '#222C59', padding: '40px' }}>
<Story />
</div>
),
],
};
export const Loaded = () => {
return <RequestReview {...props} />;
};
export const Processing = () => {
return (
<RequestReview {...props} submitReviewAttempt={makeProcessingAttempt()} />
);
};
export const Failed = () => {
return (
<RequestReview
{...props}
submitReviewAttempt={makeErrorAttempt(new Error('server error'))}
/>
);
};
const props: RequestReviewProps = {
user: 'loggedInUsername',
submitReviewAttempt: makeEmptyAttempt(),
submitReview: () => null,
shortTermDuration: '12 hours',
request: requestRolePending,
fetchSuggestedAccessListsAttempt: makeSuccessAttempt([]),
};
@@ -0,0 +1,353 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import styled from 'styled-components';
import { ButtonPrimary, Text, Box, Alert, Flex, Label } from 'design';
import { Warning } from 'design/Icon';
import { Radio } from 'design/RadioGroup';
import Validation, { Validator } from 'shared/components/Validation';
import FieldSelect from 'shared/components/FieldSelect';
import { Option } from 'shared/components/Select';
import { Attempt } from 'shared/hooks/useAsync';
import { requiredField } from 'shared/components/Validation/rules';
import { HoverTooltip } from 'shared/components/ToolTip';
import { FieldTextArea } from 'shared/components/FieldTextArea';
import { AccessRequest, RequestState } from 'shared/services/accessRequests';
import { AssumeStartTime } from '../../../AssumeStartTime/AssumeStartTime';
import { AccessDurationReview } from '../../../AccessDuration';
import { SuggestedAccessList, SubmitReview } from '../types';
type ReviewStateOption = Option<RequestState, React.ReactElement> & {
disabled?: boolean;
};
type SuggestedAcessListOption = Option<SuggestedAccessList, React.ReactElement>;
export interface RequestReviewProps {
submitReview(s: SubmitReview): void;
fetchSuggestedAccessListsAttempt: Attempt<SuggestedAccessList[]>;
shortTermDuration: string;
user: string;
submitReviewAttempt: Attempt<AccessRequest>;
request: AccessRequest;
}
export default function RequestReview({
submitReviewAttempt,
submitReview,
user,
fetchSuggestedAccessListsAttempt,
shortTermDuration,
request,
}: RequestReviewProps) {
const [reviewStateOptions] = useState<ReviewStateOption[]>(() =>
makeReviewStateOptions(
fetchSuggestedAccessListsAttempt,
shortTermDuration,
request
)
);
const [suggestedAccessListOptions] = useState<SuggestedAcessListOption[]>(
() => makeSuggestedAccessListOptions(fetchSuggestedAccessListsAttempt)
);
const [state, setState] = useState<RequestState>(reviewStateOptions[0].value);
const [reason, setReason] = useState('');
const [assumeStartTime, setStart] = useState<Date>();
const [selectedAccessList, setSelectedAccessList] =
useState<SuggestedAcessListOption>();
function onSubmitReview(validator: Validator) {
if (!validator.validate()) {
return;
}
submitReview({
state,
reason,
promotedToAccessList: selectedAccessList?.value,
assumeStartTime,
});
}
function onRequestStateChange(state: RequestState, validator: Validator) {
validator.reset();
if (state !== 'PROMOTED' && selectedAccessList) {
setSelectedAccessList(undefined);
}
setState(state);
}
// After successful submit, don't render.
if (submitReviewAttempt.status === 'success') {
return null;
}
function isChecked(currentOptionState: RequestState) {
return state !== undefined ? state === currentOptionState : undefined;
}
return (
<Validation>
{({ validator }) => (
<Box
border="1px solid"
borderColor="levels.sunken"
mt={7}
style={{ position: 'relative' }}
>
<Box bg="levels.sunken" py={1} px={3} alignItems="center">
<Text typography="h6" mr={3}>
{user} - add a review
</Text>
</Box>
<Box p={3} bg="levels.elevated">
{submitReviewAttempt.status === 'error' && (
<Alert kind="danger" children={submitReviewAttempt.statusText} />
)}
<Flex mb={3} gap="8px" flexDirection="column">
{reviewStateOptions.map((option, index) => {
const radio = (
<Radio
name={option.value}
option={option}
checked={isChecked(option.value)}
onChange={o =>
onRequestStateChange(o as RequestState, validator)
}
/>
);
if (option.value === 'APPROVED' && state === 'APPROVED') {
return (
<React.Fragment key={index}>
{radio}
<Box ml={4} mt={2} css={{ position: 'relative' }} mb={3}>
<HorizontalLine height={120} />
<Box ml={1}>
<AssumeStartTime
start={assumeStartTime}
onStartChange={setStart}
accessRequest={request}
reviewing={true}
/>
<AccessDurationReview
assumeStartTime={assumeStartTime}
accessRequest={request}
/>
</Box>
</Box>
</React.Fragment>
);
}
if (option.value === 'PROMOTED' && state === 'PROMOTED') {
return (
<React.Fragment key={index}>
{radio}
<Box ml={4} mt={2} css={{ position: 'relative' }}>
<HorizontalLine />
<FieldSelect
ml={1}
maxWidth="600px"
label={`Select a suggested Access List to add ${request.user} as a member to:`}
rule={requiredField('Required')}
value={
selectedAccessList
? {
value: selectedAccessList,
label: selectedAccessList.value.title,
}
: undefined
}
onChange={(o: SuggestedAcessListOption) =>
setSelectedAccessList(o)
}
options={suggestedAccessListOptions}
/>
</Box>
</React.Fragment>
);
}
return <React.Fragment key={index}>{radio}</React.Fragment>;
})}
</Flex>
<FieldTextArea
label="Message"
placeholder="Optional message..."
value={reason}
mb={4}
maxWidth="500px"
textAreaCss={`
font-size: 14px;
min-height: 100px;
`}
onChange={e => setReason(e.target.value)}
/>
<ButtonPrimary
disabled={submitReviewAttempt.status === 'processing'}
onClick={() => onSubmitReview(validator)}
>
Submit Review
</ButtonPrimary>
</Box>
</Box>
)}
</Validation>
);
}
function makeSuggestedAccessListOptions(
fetchSuggestedAccessListsAttempt: Attempt<SuggestedAccessList[]>
): SuggestedAcessListOption[] {
if (fetchSuggestedAccessListsAttempt.status !== 'success') {
return [];
}
return fetchSuggestedAccessListsAttempt.data.map(a => {
const traitsMap = a.grants.traits;
const grantedTraits = Object.keys(traitsMap).map(key =>
makeTraitLabel(key, traitsMap[key])
);
const combinedRolesAndGrants = [...a.grants.roles, ...grantedTraits];
const $labels = combinedRolesAndGrants.map((label, index) => (
<TinyLabel
mr={index === combinedRolesAndGrants.length - 1 ? 0 : 1}
key={`${label}${index}`}
kind="secondary"
title={label}
>
{label}
</TinyLabel>
));
return {
value: a,
label: (
<Box>
<Text>{a.title}</Text>
<TextWithSmallerLineHeight>{a.description}</TextWithSmallerLineHeight>
<Flex alignItems="center">
<TextMutedNoEllipsis>Grants:</TextMutedNoEllipsis>
<Flex flexWrap="wrap">{$labels}</Flex>
</Flex>
</Box>
),
};
});
}
function makeReviewStateOptions(
fetchSuggestedAccessListsAttempt: Attempt<SuggestedAccessList[]>,
shortTermDuration: string,
request: AccessRequest
): ReviewStateOption[] {
const promotedTxt =
'Approve long-term access via Access List with the requested resources';
let promotedContent;
if (
fetchSuggestedAccessListsAttempt.status === 'success' &&
fetchSuggestedAccessListsAttempt.data.length > 0
) {
promotedContent = <Text>{promotedTxt}</Text>;
} else {
let msg = 'No Access Lists will grant the requested resources';
if (fetchSuggestedAccessListsAttempt.status === 'error') {
msg = fetchSuggestedAccessListsAttempt.statusText;
} else if (request.resources.length === 0) {
msg = 'Only supported for resource based access requests';
}
promotedContent = (
<HoverTooltip tipContent={msg}>
<Flex alignItems="center">
<Text>{promotedTxt}</Text>
{fetchSuggestedAccessListsAttempt.status === 'error' && (
<Warning color="warning.active" ml={1} size={20} />
)}
</Flex>
</HoverTooltip>
);
}
return [
{ value: 'DENIED', label: <>Reject request</> },
{
value: 'APPROVED',
label: (
<>
Approve short-term access
{shortTermDuration ? ` (${shortTermDuration})` : ''}
</>
),
},
{
value: 'PROMOTED',
disabled:
fetchSuggestedAccessListsAttempt.status === 'error' ||
(fetchSuggestedAccessListsAttempt.status === 'success' &&
fetchSuggestedAccessListsAttempt.data.length === 0),
label: <>{promotedContent}</>,
},
];
}
const TextMutedNoEllipsis = styled.div`
font-size: ${p => p.theme.fontSizes[0]}px;
margin-right: ${p => p.theme.space[1]}px;
color: ${p => p.theme.colors.text.slightlyMuted};
`;
const TinyLabel = styled(Label)`
font-size: 8px;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 14px;
`;
const TextWithSmallerLineHeight = styled(Text)`
line-height: 16px;
font-size: ${p => p.theme.fontSizes[0]}px;
color: ${p => p.theme.colors.text.muted};
`;
const HorizontalLine = styled.div`
width: 2px;
height: ${p => p.height || 92}px;
background-color: ${props => props.theme.colors.spotBackground[0]};
position: absolute;
top: -10px;
left: -10px;
`;
// TODO(gzdunek): Create a shared implementation.
// This was copied from `AccessListManagement`.
function makeTraitLabel(traitKey: string, traitVals: string[]) {
return `${traitKey}: ${traitVals.sort().join(', ')}`;
}
@@ -0,0 +1,616 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`failed state 1`] = `
.c4 {
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
box-sizing: border-box;
box-shadow: 0 1px 4px rgba(0,0,0,0.24);
margin: 0 0 24px 0;
min-height: 40px;
padding: 8px 16px;
overflow: auto;
word-break: break-word;
line-height: 1.5;
background: #FF6257;
color: #000000;
}
.c4 a {
color: #FFFFFF;
}
.c0 {
box-sizing: border-box;
margin-top: 48px;
border: 1px solid;
border-color: #0C143D;
}
.c1 {
box-sizing: border-box;
padding-left: 16px;
padding-right: 16px;
padding-top: 4px;
padding-bottom: 4px;
background-color: #0C143D;
}
.c3 {
box-sizing: border-box;
padding: 16px;
background-color: #344179;
}
.c5 {
box-sizing: border-box;
margin-bottom: 16px;
}
.c12 {
box-sizing: border-box;
}
.c16 {
box-sizing: border-box;
max-width: 500px;
margin-bottom: 24px;
}
.c20 {
line-height: 1.5;
margin: 0;
display: inline-flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: 600;
outline: none;
position: relative;
text-align: center;
text-decoration: none;
text-transform: uppercase;
transition: all 0.3s;
-webkit-font-smoothing: antialiased;
color: #000000;
background: #9F85FF;
min-height: 32px;
font-size: 12px;
padding: 0px 24px;
}
.c20:hover,
.c20:focus {
background: #B29DFF;
}
.c20:active {
background: #C5B6FF;
}
.c20:disabled {
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.3);
cursor: auto;
}
.c2 {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-size: 14px;
line-height: 24px;
margin: 0px;
margin-right: 16px;
}
.c15 {
overflow: hidden;
text-overflow: ellipsis;
margin: 0px;
}
.c17 {
color: #FFFFFF;
display: block;
font-size: 12px;
width: 100%;
margin-bottom: 0px;
}
.c6 {
display: flex;
flex-direction: column;
gap: 8px;
}
.c13 {
display: flex;
}
.c14 {
display: flex;
align-items: center;
}
.c18 {
appearance: none;
border: 1px solid rgba(255,255,255,0.54);
border-radius: 4px;
box-sizing: border-box;
min-height: 50px;
height: 80px;
font-size: 16px;
padding: 16px;
outline: none;
width: 100%;
color: #FFFFFF;
background: inherit;
margin-top: 4px;
resize: vertical;
}
.c18::placeholder {
color: rgba(255,255,255,0.54);
opacity: 1;
}
.c18:hover,
.c18:focus,
.c18:active {
border: 1px solid rgba(255,255,255,0.72);
}
.c18:read-only {
cursor: not-allowed;
}
.c18:disabled {
color: rgba(255,255,255,0.36);
border-color: rgba(255,255,255,0.36);
}
.c7 {
display: flex;
align-items: center;
cursor: pointer;
}
.c10 {
display: flex;
align-items: center;
cursor: not-allowed;
}
.c8 {
margin: 0 8px 0 0;
accent-color: #9F85FF;
cursor: inherit;
}
.c9 {
opacity: 1;
}
.c11 {
opacity: 0.5;
}
.c19 {
font-size: 14px;
min-height: 100px;
}
<div>
<div
class="c0"
style="position: relative;"
>
<div
class="c1"
>
<div
class="c2"
>
loggedInUsername
- add a review
</div>
</div>
<div
class="c3"
>
<div
class="c4"
kind="danger"
>
server error
</div>
<div
class="c5 c6"
>
<label
class="c7"
>
<input
checked=""
class="c8"
name="DENIED"
type="radio"
value="DENIED"
/>
<span
class="c9"
>
Reject request
</span>
</label>
<label
class="c7"
>
<input
class="c8"
name="APPROVED"
type="radio"
value="APPROVED"
/>
<span
class="c9"
>
Approve short-term access
(12 hours)
</span>
</label>
<label
class="c10"
>
<input
class="c8"
disabled=""
name="PROMOTED"
type="radio"
value="PROMOTED"
/>
<span
class="c11"
>
<div
class="c12 c13"
>
<div
class="c12 c14"
>
<div
class="c15"
>
Approve long-term access via Access List with the requested resources
</div>
</div>
</div>
</span>
</label>
</div>
<div
class="c16"
>
<label
class="c17"
font-size="0"
>
Message
<textarea
autocomplete="off"
class="c18 c19"
placeholder="Optional message..."
/>
</label>
</div>
<button
class="c20"
kind="primary"
>
Submit Review
</button>
</div>
</div>
</div>
`;
exports[`loaded state 1`] = `
.c0 {
box-sizing: border-box;
margin-top: 48px;
border: 1px solid;
border-color: #0C143D;
}
.c1 {
box-sizing: border-box;
padding-left: 16px;
padding-right: 16px;
padding-top: 4px;
padding-bottom: 4px;
background-color: #0C143D;
}
.c3 {
box-sizing: border-box;
padding: 16px;
background-color: #344179;
}
.c4 {
box-sizing: border-box;
margin-bottom: 16px;
}
.c11 {
box-sizing: border-box;
}
.c15 {
box-sizing: border-box;
max-width: 500px;
margin-bottom: 24px;
}
.c19 {
line-height: 1.5;
margin: 0;
display: inline-flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: 600;
outline: none;
position: relative;
text-align: center;
text-decoration: none;
text-transform: uppercase;
transition: all 0.3s;
-webkit-font-smoothing: antialiased;
color: #000000;
background: #9F85FF;
min-height: 32px;
font-size: 12px;
padding: 0px 24px;
}
.c19:hover,
.c19:focus {
background: #B29DFF;
}
.c19:active {
background: #C5B6FF;
}
.c19:disabled {
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.3);
cursor: auto;
}
.c2 {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-size: 14px;
line-height: 24px;
margin: 0px;
margin-right: 16px;
}
.c14 {
overflow: hidden;
text-overflow: ellipsis;
margin: 0px;
}
.c16 {
color: #FFFFFF;
display: block;
font-size: 12px;
width: 100%;
margin-bottom: 0px;
}
.c5 {
display: flex;
flex-direction: column;
gap: 8px;
}
.c12 {
display: flex;
}
.c13 {
display: flex;
align-items: center;
}
.c17 {
appearance: none;
border: 1px solid rgba(255,255,255,0.54);
border-radius: 4px;
box-sizing: border-box;
min-height: 50px;
height: 80px;
font-size: 16px;
padding: 16px;
outline: none;
width: 100%;
color: #FFFFFF;
background: inherit;
margin-top: 4px;
resize: vertical;
}
.c17::placeholder {
color: rgba(255,255,255,0.54);
opacity: 1;
}
.c17:hover,
.c17:focus,
.c17:active {
border: 1px solid rgba(255,255,255,0.72);
}
.c17:read-only {
cursor: not-allowed;
}
.c17:disabled {
color: rgba(255,255,255,0.36);
border-color: rgba(255,255,255,0.36);
}
.c6 {
display: flex;
align-items: center;
cursor: pointer;
}
.c9 {
display: flex;
align-items: center;
cursor: not-allowed;
}
.c7 {
margin: 0 8px 0 0;
accent-color: #9F85FF;
cursor: inherit;
}
.c8 {
opacity: 1;
}
.c10 {
opacity: 0.5;
}
.c18 {
font-size: 14px;
min-height: 100px;
}
<div>
<div
class="c0"
style="position: relative;"
>
<div
class="c1"
>
<div
class="c2"
>
loggedInUsername
- add a review
</div>
</div>
<div
class="c3"
>
<div
class="c4 c5"
>
<label
class="c6"
>
<input
checked=""
class="c7"
name="DENIED"
type="radio"
value="DENIED"
/>
<span
class="c8"
>
Reject request
</span>
</label>
<label
class="c6"
>
<input
class="c7"
name="APPROVED"
type="radio"
value="APPROVED"
/>
<span
class="c8"
>
Approve short-term access
(12 hours)
</span>
</label>
<label
class="c9"
>
<input
class="c7"
disabled=""
name="PROMOTED"
type="radio"
value="PROMOTED"
/>
<span
class="c10"
>
<div
class="c11 c12"
>
<div
class="c11 c13"
>
<div
class="c14"
>
Approve long-term access via Access List with the requested resources
</div>
</div>
</div>
</span>
</label>
</div>
<div
class="c15"
>
<label
class="c16"
font-size="0"
>
Message
<textarea
autocomplete="off"
class="c17 c18"
placeholder="Optional message..."
/>
</label>
</div>
<button
class="c19"
kind="primary"
>
Submit Review
</button>
</div>
</div>
</div>
`;
@@ -0,0 +1,21 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import RequestReview from './RequestReview';
export default RequestReview;
@@ -0,0 +1,48 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { render } from 'design/utils/testing';
import {
LoadedSearchPending,
LoadedRolePending,
LoadedRoleApproved,
LoadedRoleDenied,
} from './RequestView.story';
test('loaded pending role based request state', () => {
const { container } = render(<LoadedRolePending />);
expect(container).toMatchSnapshot();
});
test('loaded pending search based request state', () => {
const { container } = render(<LoadedSearchPending />);
expect(container).toMatchSnapshot();
});
test('loaded approved role based request state', () => {
const { container } = render(<LoadedRoleApproved />);
expect(container).toMatchSnapshot();
});
test('loaded denied role based request state', () => {
const { container } = render(<LoadedRoleDenied />);
expect(container).toMatchSnapshot();
});
@@ -0,0 +1,266 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import {
makeSuccessAttempt,
makeEmptyAttempt,
makeProcessingAttempt,
makeErrorAttempt,
} from 'shared/hooks/useAsync';
import {
requestRoleApproved,
requestRoleDenied,
requestRolePending,
requestSearchPending,
requestRoleEmpty,
requestRolePromoted,
requestRoleApprovedWithStartTime,
} from '../../fixtures';
import { RequestView, RequestViewProps } from './RequestView';
import { RequestFlags, SuggestedAccessList } from './types';
export default {
title: 'Shared/AccessRequests/RequestView',
};
export const LoadedSearchPending = () => {
const flags = {
...sampleFlags,
canReview: true,
canDelete: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestSearchPending)}
getFlags={() => flags}
/>
);
};
export const LoadedRolePending = () => {
const flags = {
...sampleFlags,
canReview: true,
canDelete: true,
};
return <RequestView {...sample} getFlags={() => flags} />;
};
export const LoadedRoleDenied = () => {
const flags = {
...sampleFlags,
canDelete: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestRoleDenied)}
getFlags={() => flags}
/>
);
};
export const LoadedRoleApproved = () => {
const flags = {
...sampleFlags,
canDelete: true,
canAssume: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestRoleApproved)}
getFlags={() => flags}
/>
);
};
export const LoadedRoleApprovedWithStartTime = () => {
const flags = {
...sampleFlags,
canAssume: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestRoleApprovedWithStartTime)}
getFlags={() => flags}
/>
);
};
export const AccessListPromoted = () => {
const flags = {
...sampleFlags,
isPromoted: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestRolePromoted)}
getFlags={() => flags}
fetchSuggestedAccessListsAttempt={makeSuccessAttempt(
suggestedAccessLists
)}
/>
);
};
export const AccessListPromotedOwnRequest = () => {
const flags = {
...sampleFlags,
isPromoted: true,
ownRequest: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestRolePromoted)}
getFlags={() => flags}
fetchSuggestedAccessListsAttempt={makeSuccessAttempt(
suggestedAccessLists
)}
/>
);
};
export const AccessListPending = () => {
const flags = {
...sampleFlags,
canReview: true,
};
return (
<RequestView
{...sample}
getFlags={() => flags}
fetchSuggestedAccessListsAttempt={makeSuccessAttempt(
suggestedAccessLists
)}
/>
);
};
export const AccessListPendingWithError = () => {
const flags = {
...sampleFlags,
canReview: true,
};
return (
<RequestView
{...sample}
getFlags={() => flags}
fetchSuggestedAccessListsAttempt={makeErrorAttempt(
new Error('some kind of error came back from the backend')
)}
/>
);
};
export const LoadedEmpty = () => {
const flags = {
...sampleFlags,
canAssume: true,
isAssumed: true,
};
return (
<RequestView
{...sample}
fetchRequestAttempt={makeSuccessAttempt(requestRoleEmpty)}
getFlags={() => flags}
/>
);
};
export const Processing = () => {
return (
<RequestView {...sample} fetchRequestAttempt={makeProcessingAttempt()} />
);
};
export const Failed = () => {
return (
<RequestView
{...sample}
fetchRequestAttempt={makeErrorAttempt(new Error('some error message'))}
/>
);
};
const sample: RequestViewProps = {
user: 'loggedInUsername',
fetchRequestAttempt: makeSuccessAttempt(requestRolePending),
submitReviewAttempt: makeEmptyAttempt(),
getFlags: () => sampleFlags,
confirmDelete: false,
toggleConfirmDelete: () => null,
submitReview: () => null,
assumeRole: () => null,
fetchSuggestedAccessListsAttempt: makeSuccessAttempt([]),
assumeRoleAttempt: makeEmptyAttempt(),
assumeAccessList: () => null,
deleteRequestAttempt: makeEmptyAttempt(),
deleteRequest: () => null,
};
const sampleFlags: RequestFlags = {
canAssume: false,
isAssumed: false,
canDelete: false,
canReview: false,
ownRequest: false,
isPromoted: false,
};
const suggestedAccessLists: SuggestedAccessList[] = [
{
id: 'id-123456',
title: 'Design Team',
description: 'some description about this design team access list',
grants: {
roles: ['access', 'editor'],
traits: { fruit: ['apple'], drink: ['mocha', 'latte', 'capppuccino'] },
},
},
{
id: 'id-9876',
title: 'Managers',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat',
grants: {
roles: [
'access',
'devices',
'editor',
'devices',
'reviewer',
'auditor',
'some really long role name goerge washington',
'admin',
'intern',
'devices',
'devices',
],
traits: { fruit: ['apple'] },
},
},
];
@@ -0,0 +1,704 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import styled from 'styled-components';
import { format } from 'date-fns';
import {
Alert,
Box,
ButtonBorder,
ButtonPrimary,
Flex,
Indicator,
LabelState,
Text,
Label,
} from 'design';
import {
ChevronCircleDown,
CircleCheck,
CircleCross,
ArrowFatLinesUp,
} from 'design/Icon';
import { TeleportGearIcon } from 'design/SVGIcon';
import Table from 'design/DataTable';
import { HoverTooltip } from 'shared/components/ToolTip';
import { hasFinished, Attempt } from 'shared/hooks/useAsync';
import cfg from 'shared/config';
import {
canAssumeNow,
AccessRequestReview,
AccessRequestReviewer,
RequestState,
Resource,
AccessRequest,
} from 'shared/services/accessRequests';
import {
PromotedMessage,
getAssumeStartTimeTooltipText,
} from '../../Shared/Shared';
import { getFormattedDurationTxt } from '../../Shared/utils';
import { formattedName } from '../formattedName';
import RequestReview from './RequestReview';
import RolesRequested from './RolesRequested';
import { SuggestedAccessList } from './types';
import { RequestDelete } from './RequestDelete';
import type {
RequestFlags,
SubmitReview,
} from '../../ReviewRequests/RequestView/types';
export interface RequestViewProps {
user: string;
getFlags(accessRequest: AccessRequest): RequestFlags;
fetchRequestAttempt: Attempt<AccessRequest>;
fetchSuggestedAccessListsAttempt: Attempt<SuggestedAccessList[]>;
toggleConfirmDelete(): void;
confirmDelete: boolean;
submitReview(s: SubmitReview): void;
submitReviewAttempt: Attempt<AccessRequest>;
assumeRole(accessRequest: AccessRequest): void;
assumeRoleAttempt: Attempt<void>;
assumeAccessList(): void;
deleteRequestAttempt: Attempt<void>;
deleteRequest(): void;
}
export function RequestView({
user,
fetchRequestAttempt,
getFlags,
confirmDelete,
toggleConfirmDelete,
submitReview,
assumeRole,
submitReviewAttempt,
assumeRoleAttempt,
fetchSuggestedAccessListsAttempt,
assumeAccessList,
deleteRequestAttempt,
deleteRequest,
}: RequestViewProps) {
if (
!hasFinished(fetchRequestAttempt) ||
!hasFinished(fetchSuggestedAccessListsAttempt)
) {
return (
<Box textAlign="center" m={10}>
<Indicator delay="short" />
</Box>
);
}
if (fetchRequestAttempt.status === 'error') {
return <Alert kind="danger" children={fetchRequestAttempt.statusText} />;
}
if (assumeRoleAttempt.status === 'error') {
return <Alert kind="danger" children={assumeRoleAttempt.statusText} />;
}
const request =
submitReviewAttempt.status === 'success'
? submitReviewAttempt.data
: fetchRequestAttempt.data;
const flags = getFlags(request);
let assumeBtn;
if (flags.canAssume) {
if (canAssumeNow(request.assumeStartTime)) {
assumeBtn = (
<ButtonPrimary
disabled={
flags.isAssumed || assumeRoleAttempt.status === 'processing'
}
onClick={() => assumeRole(request)}
mt={4}
>
{flags.isAssumed ? 'Assumed' : 'Assume Roles'}
</ButtonPrimary>
);
} else {
assumeBtn = (
<Box mt={4}>
<HoverTooltip
tipContent={getAssumeStartTimeTooltipText(request.assumeStartTime)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<ButtonPrimary disabled={true}>Assume Roles</ButtonPrimary>
</HoverTooltip>
</Box>
);
}
}
let requestedAccessTime = getFormattedDurationTxt({
start: request.created,
end: request.expires,
});
let startingTime = format(request.created, cfg.dateWithPrefixedTime);
if (request.assumeStartTime) {
startingTime = format(request.assumeStartTime, cfg.dateWithPrefixedTime);
requestedAccessTime = getFormattedDurationTxt({
start: request.assumeStartTime,
end: request.expires,
});
}
return (
<>
{confirmDelete && (
<RequestDelete
user={request.user}
roles={request.roles}
requestId={request.id}
requestState={request.state}
onClose={toggleConfirmDelete}
onDelete={deleteRequest}
deleteRequestAttempt={deleteRequestAttempt}
/>
)}
<Flex>
{/* Left box contains: status, timestamps, and comments */}
<Box
mr={5}
width="100%"
minWidth="515px"
maxWidth="860px"
flex="1 1 auto"
>
<Box
css={`
box-shadow: ${props => props.theme.boxShadow[0]};
`}
>
{/* First half of this box contains status, roles, expiry, and delete btn */}
<Flex
p={3}
borderTopLeftRadius={2}
borderTopRightRadius={2}
css={`
background: ${props =>
props.theme.type === 'light'
? props.theme.colors.spotBackground[0]
: props.theme.colors.levels.elevated};
`}
>
<Flex alignItems="center">
<StateLabel
state={request.state}
mr={3}
px={3}
py={1}
style={{ fontWeight: 'bold' }}
/>
<Flex flexWrap="wrap" alignItems="center">
<Text
mr={1}
typography="body2"
title={request.user}
bold
style={{
maxWidth: '120px',
}}
>
{request.user}
</Text>
<Text
mr={2}
typography="body2"
style={{
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
is requesting roles:
</Text>
<RolesRequested roles={request.roles} />
<Text typography="body2">
for {requestedAccessTime}, starting {startingTime}
</Text>
</Flex>
</Flex>
<Flex
alignItems="center"
justifyContent="flex-end"
flexWrap="wrap-reverse"
flex="1"
gap={2}
>
{request.requestTTLDuration && request.state === 'PENDING' && (
<RequestTtlLabel
fontSize={0}
css={{ lineHeight: 'normal' }}
ml={1}
>
Request expires in {request.requestTTLDuration}
</RequestTtlLabel>
)}
<ButtonBorder
disabled={!flags.canDelete}
onClick={toggleConfirmDelete}
size="small"
width="60px"
>
Delete
</ButtonBorder>
</Flex>
</Flex>
{/* Second half of this box contains timestamp & comments*/}
<TimelineCommentAndReviewsContainer
bg="levels.surface"
p={4}
pt={0}
borderBottomLeftRadius={2}
borderBottomRightRadius={2}
style={{ position: 'relative' }}
>
<Timeline />
<RequestorTimestamp
user={request.user}
reason={request.requestReason}
createdDuration={request.createdDuration}
resources={request.resources}
/>
{request.reviews.length > 0 && (
<Reviews reviews={request.reviews} />
)}
{request.state === 'PENDING' &&
fetchSuggestedAccessListsAttempt.status === 'success' &&
fetchSuggestedAccessListsAttempt.data.length > 0 && (
<SuggestedAccessListTimestamp
accessLists={fetchSuggestedAccessListsAttempt.data}
/>
)}
{flags.canReview && (
<RequestReview
submitReview={submitReview}
user={user}
submitReviewAttempt={submitReviewAttempt}
fetchSuggestedAccessListsAttempt={
fetchSuggestedAccessListsAttempt
}
shortTermDuration={requestedAccessTime}
request={request}
/>
)}
</TimelineCommentAndReviewsContainer>
</Box>
{assumeBtn}
{request.state === 'PROMOTED' && request.promotedAccessListTitle && (
<PromotedMessage
request={request}
self={flags.ownRequest}
py={4}
assumeAccessList={assumeAccessList}
/>
)}
</Box>
{/* Right box contains reviewers and threshold list */}
<Box flex="0 1 260px" minWidth="120px">
<Reviewers reviewers={request.reviewers} />
<Box mt={3} ml={1}>
<Text typography="body2" color="text.slightlyMuted">
Thresholds: {request.thresholdNames.join(', ')}
</Text>
</Box>
</Box>
</Flex>
</>
);
}
export const Timeline = styled.div`
position: absolute;
height: calc(100% - 34px);
width: 2px;
top: 0;
left: 55px;
border-left: 2px solid ${props => props.theme.colors.spotBackground[0]};
`;
export function RequestorTimestamp({
user,
reason,
createdDuration,
resources,
}: {
user: string;
reason: string;
createdDuration: string;
resources: Resource[];
}) {
return (
<>
<Timestamp author={user} createdDuration={createdDuration} />
{(reason || resources?.length > 0) && (
<Comment
author={user}
comment={reason}
createdDuration={createdDuration}
resources={resources}
/>
)}
</>
);
}
export function Timestamp({
author,
state,
createdDuration,
promotedAccessListTitle,
assumeStartTime,
}: {
author: string;
state?: RequestState;
createdDuration: string;
promotedAccessListTitle?: string;
assumeStartTime?: Date;
}) {
const isPromoted = state === 'PROMOTED' && promotedAccessListTitle;
let iconBgColor = 'levels.elevated';
let $icon = <ChevronCircleDown size={26} color="text.muted" />;
let verb = `submitted`;
if (state === 'APPROVED') {
iconBgColor = 'success.main';
$icon = <CircleCheck size={26} color="light" />;
verb = 'approved';
}
if (isPromoted) {
iconBgColor = 'success.main';
$icon = <ArrowFatLinesUp size={26} color="light" />;
verb = 'promoted';
}
if (state === 'DENIED') {
iconBgColor = 'error.main';
$icon = <CircleCross size={26} color="light" />;
verb = 'denied';
}
return (
<Flex alignItems="center" pt={3} style={{ position: 'relative' }}>
<Box
ml={3}
mr={2}
bg={iconBgColor}
p="3px"
borderRadius="50%"
style={{ display: 'flex' }}
>
{$icon}
</Box>
<Box alignItems="baseline">
<b>{author}</b>{' '}
{!isPromoted ? (
assumeStartTime ? (
<span>
modified the start time and {verb} this request {createdDuration}
</span>
) : (
<span>
{verb} this request {createdDuration}
</span>
)
) : (
<span>
{verb} this request to long-term access with access list{' '}
<b>{promotedAccessListTitle}</b> {createdDuration}
</span>
)}
</Box>
</Flex>
);
}
function Comment({
author,
comment,
createdDuration,
resources,
}: {
author: string;
comment: string;
createdDuration: string;
resources?: Resource[];
}) {
return (
<Box
border="1px solid"
borderColor="levels.sunken"
mt={3}
style={{ position: 'relative' }}
>
<Flex bg="levels.sunken" py={1} px={3} alignItems="baseline">
<Text typography="body2" bold mr={2}>
{author}
</Text>
<Text typography="paragraph2">{createdDuration}</Text>
</Flex>
{comment && (
<Box p={3} bg="levels.elevated">
{comment}
</Box>
)}
{resources?.length > 0 && (
<Box
pt={comment ? 0 : 3}
pl={3}
pr={0}
pb={3}
css={`
margin: 0 auto;
`}
bg="levels.elevated"
>
<StyledTable
data={resources.map(resource => ({
...resource.id,
...resource.details,
name: resource.details?.friendlyName || formattedName(resource),
}))}
columns={[
{
key: 'clusterName',
headerText: 'Cluster Name',
},
{
key: 'kind',
headerText: 'Requested Resource Kind',
},
{
key: 'name',
headerText: 'Requested Resource Name',
},
]}
emptyText=""
/>
</Box>
)}
</Box>
);
}
function Reviewers({ reviewers }: { reviewers: AccessRequestReviewer[] }) {
const $reviewers = reviewers.map((reviewer, index) => {
let kind = 'warning';
if (reviewer.state === 'APPROVED' || reviewer.state === 'PROMOTED') {
kind = 'success';
} else if (reviewer.state === 'DENIED') {
kind = 'danger';
}
return (
<Flex
border={1}
borderColor="levels.surface"
borderRadius={1}
px={3}
py={2}
mb={2}
alignItems="center"
justifyContent="space-between"
key={index}
css={`
background: ${props => props.theme.colors.spotBackground[0]};
`}
>
<Text
typography="body2"
bold
mr={3}
style={{
whiteSpace: 'nowrap',
maxWidth: '200px',
}}
title={reviewer.name}
>
{reviewer.name}
</Text>
<LabelState
kind={kind}
width="10px"
p={0}
style={{
minHeight: '10px',
minWidth: '10px',
}}
/>
</Flex>
);
});
if ($reviewers.length === 0) {
return (
<>
<Flex
borderBottom={1}
mb={3}
pb={3}
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Text typography="h6" mr={2}>
No Reviewers Yet
</Text>
</Flex>
{$reviewers}
</>
);
}
return (
<>
<Flex
borderBottom={1}
mb={3}
pb={3}
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Text typography="h6" mr={2}>
Reviewers
</Text>
</Flex>
{$reviewers}
</>
);
}
function StateLabel(props: { state: RequestState; [key: string]: any }) {
const { state, ...styles } = props;
switch (state) {
case 'APPROVED':
case 'PROMOTED':
return (
<LabelState kind="success" {...styles}>
{state}
</LabelState>
);
case 'DENIED':
return (
<LabelState kind="danger" {...styles}>
{state}
</LabelState>
);
case 'PENDING':
return (
<LabelState kind="warning" {...styles}>
{state}
</LabelState>
);
}
}
function Reviews({ reviews }: { reviews: AccessRequestReview[] }) {
const $reviews = reviews.map((review, index) => {
const { author, state, createdDuration, reason, promotedAccessListTitle } =
review;
return (
<React.Fragment key={index}>
<Timestamp
author={author}
state={state}
createdDuration={createdDuration}
promotedAccessListTitle={promotedAccessListTitle}
assumeStartTime={review.assumeStartTime}
/>
{reason && (
<Comment
author={author}
comment={reason}
createdDuration={createdDuration}
/>
)}
</React.Fragment>
);
});
return <Box>{$reviews}</Box>;
}
export function SuggestedAccessListTimestamp({
accessLists,
}: {
accessLists: SuggestedAccessList[];
}) {
return (
<Flex pt={3} style={{ position: 'relative' }}>
<Box ml={3} mr={2}>
<TeleportGearIcon size={32} />
</Box>
<Box>
<Text>
<BrandName>Teleport</BrandName> identified {accessLists.length} access
lists which grants similar requested resources:
</Text>
<Flex gap={2} flexWrap="wrap">
{accessLists.map(acl => (
<Label key={acl.id} kind="secondary">
{acl.title}
</Label>
))}
</Flex>
</Box>
</Flex>
);
}
const StyledTable = styled(Table)`
width: 90%;
& > tbody > tr > td {
vertical-align: middle;
}
` as typeof Table;
const BrandName = styled.span`
font-weight: bold;
color: ${p => p.theme.colors.brand};
`;
export const TimelineCommentAndReviewsContainer = styled.div`
position: relative;
background-color: ${p => p.theme.colors.levels.surface};
padding: ${p => p.theme.space[4]}px;
padding-top: 0;
border-bottom-left-radius: ${p => p.theme.radii[4]}px;
border-bottom-right-radius: ${p => p.theme.radii[4]}px;
`;
const RequestTtlLabel = styled(Text)`
font-style: italic;
`;
@@ -0,0 +1,30 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Box, Label } from 'design';
export default function RolesRequested({ roles }: { roles: string[] }) {
const $roles = roles.sort().map(role => (
<Label mr="1" key={role} kind="secondary">
{role}
</Label>
));
return <Box>{$roles}</Box>;
}
@@ -0,0 +1,21 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export * from './RequestView';
export * from './RequestDelete';
export type * from './types';
@@ -0,0 +1,53 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { AllUserTraits } from 'teleport/services/user';
import { RequestState } from 'shared/services/accessRequests';
export type RequestFlags = {
/** Describes request is own request and request is approved */
canAssume: boolean;
/**
* Decides if the button to assume a request should be disabled
* and determines the text on it.
*/
isAssumed: boolean;
canReview: boolean;
canDelete: boolean;
ownRequest: boolean;
isPromoted: boolean;
};
/** Subset of `AccessList` properties required to show a suggestion. */
export type SuggestedAccessList = {
id: string;
title: string;
description?: string;
grants: {
roles: string[];
traits: AllUserTraits;
};
};
export type SubmitReview = {
state: RequestState;
reason: string;
promotedToAccessList?: SuggestedAccessList;
assumeStartTime?: Date;
};
@@ -0,0 +1,27 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { Resource } from 'shared/services/accessRequests';
export function formattedName(resource: Resource) {
const id = resource.id;
if (id.subResourceName) {
return `${id.name}/${id.subResourceName}`;
}
return id.name;
}
@@ -0,0 +1,21 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export * from './RequestView';
export * from './RequestList';
export { formattedName } from './formattedName';
@@ -0,0 +1,141 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useState } from 'react';
import { ButtonPrimary, Text, Box, ButtonIcon, Menu } from 'design';
import { Info } from 'design/Icon';
import { format } from 'date-fns';
import { HoverTooltip } from 'shared/components/ToolTip';
import cfg from 'shared/config';
import { AccessRequest } from 'shared/services/accessRequests';
export function PromotedMessage({
request,
px,
py,
self,
assumeAccessList,
}: {
request: AccessRequest;
self: boolean;
px?: number;
py?: number;
assumeAccessList(): void;
}) {
const { promotedAccessListTitle, user } = request;
return (
<Box px={px} py={py}>
<Text>
This access request has been promoted to long-term access.
<br />
{self ? (
<>
You are now a member of Access List <b>{promotedAccessListTitle}</b>{' '}
which grants you the resources requested.
</>
) : (
<>
{user} is now a member of Access List{' '}
<b>{promotedAccessListTitle}</b> which grants {user} the resources
requested.
</>
)}
</Text>
{self && (
<ButtonPrimary mt={3} onClick={assumeAccessList}>
Re-login to gain access
</ButtonPrimary>
)}
</Box>
);
}
export const ButtonPromotedInfo = ({
request,
ownRequest,
assumeAccessList,
}: {
request: AccessRequest;
ownRequest: boolean;
assumeAccessList(): void;
}) => {
const [anchorEl, setAnchorEl] = useState(null);
const handleOpen = event => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Box css={{ margin: '0 auto' }}>
<ButtonIcon onClick={handleOpen}>
<Info />
</ButtonIcon>
<Menu
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<PromotedMessage
request={request}
self={ownRequest}
assumeAccessList={assumeAccessList}
px={4}
py={4}
/>
</Menu>
</Box>
);
};
export function getAssumeStartTimeTooltipText(startTime: Date) {
const formattedDate = format(startTime, cfg.dateWithPrefixedTime);
return `Access is not available until the approved time of ${formattedDate}`;
}
export const BlockedByStartTimeButton = ({
assumeStartTime,
}: {
assumeStartTime: Date;
}) => {
return (
<HoverTooltip
tipContent={getAssumeStartTimeTooltipText(assumeStartTime)}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<ButtonPrimary disabled={true} size="small">
Assume Roles
</ButtonPrimary>
</HoverTooltip>
);
};
@@ -0,0 +1,30 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { Option } from 'shared/components/Select';
export type TimeOption = Option<Date>;
export type CreateRequest = {
reason?: string;
start?: Date;
suggestedReviewers?: string[];
maxDuration?: Date;
requestTTL?: Date;
dryRun?: boolean;
};
@@ -0,0 +1,29 @@
/**
* Copyright 2024 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 { formatDuration, intervalToDuration } from 'date-fns';
export function getFormattedDurationTxt({
start,
end,
}: {
start: Date;
end: Date;
}) {
return formatDuration(intervalToDuration({ start, end }), {
format: ['weeks', 'days', 'hours', 'minutes'],
});
}
@@ -0,0 +1,350 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { AccessRequest } from 'shared/services/accessRequests';
export const dryRunResponse: AccessRequest = {
id: 'e9803adc-3260-4c49-baae-047494da2822',
state: 'PENDING',
resolveReason: '',
requestReason: '',
user: 'lisa',
roles: ['auditor'],
created: new Date('2024-02-15T02:51:00.000088Z'),
createdDuration: '',
expires: new Date('2024-02-17T02:51:12.70087Z'),
expiresDuration: '',
maxDuration: new Date('2024-02-17T02:51:12.70087Z'),
maxDurationText: '',
requestTTL: new Date('2024-02-15T03:51:12.70087Z'),
requestTTLDuration: '',
sessionTTL: new Date('2024-02-15T14:51:03.999893Z'),
sessionTTLDuration: '',
reviews: [],
reviewers: [],
thresholdNames: ['default'],
resources: [],
assumeStartTime: null,
};
export const requestSearchPending: AccessRequest = {
id: '461ff4bb-62f1-53b5-84ae-731022261a12',
state: 'PENDING',
user: 'Sam',
expires: new Date('12-6-2020'),
expiresDuration: '35 minutes',
created: new Date('12-4-2020'),
createdDuration: '1 minute ago',
maxDuration: new Date('12-6-2020'),
maxDurationText: '',
requestTTL: new Date('12-5-2020'),
requestTTLDuration: '1 hour',
sessionTTL: new Date('12-5-2020'),
sessionTTLDuration: '',
roles: ['test'],
requestReason:
'Testing long message format. I am requesting access for the developer role that i will be using to \
commit fixes for our production application. I will need access for the \
rest of the day to complete my changes.',
resolveReason: '',
reviews: [],
reviewers: [
{
name: 'alice',
state: 'PENDING',
},
{
name: 'bob',
state: 'PENDING',
},
],
thresholdNames: ['Default', 'Poplar', 'Admin'],
resources: [
{
id: {
kind: 'app',
name: 'app-name',
clusterName: 'cluster-name',
},
},
{
id: {
kind: 'db',
name: 'db-name',
clusterName: 'cluster-name',
},
},
{
id: {
kind: 'node',
name: 'node-name',
clusterName: 'cluster-name',
},
},
{
id: {
kind: 'user_group',
name: 'user-group-name',
clusterName: 'cluster-name',
},
},
{
id: {
kind: 'kube_cluster',
name: 'kube-cluster-name',
clusterName: 'cluster-name',
},
},
{
id: {
kind: 'windows_desktop',
name: 'windows-desktop-name',
clusterName: 'cluster-name',
},
},
{
id: {
kind: 'app',
name: 'raw-id',
clusterName: 'cluster-name',
},
details: {
friendlyName: 'Some Friendly Name',
},
},
],
};
export const requestRolePending: AccessRequest = {
id: '461ff4bb-62f1-53b5-84ae-731022261a12',
state: 'PENDING',
user: 'Sam',
expires: new Date('12-6-2020'),
expiresDuration: '35 minutes',
created: new Date('12-4-2020'),
createdDuration: '1 minute ago',
maxDuration: new Date('12-6-2020'),
maxDurationText: '',
requestTTL: new Date('12-6-2020'),
requestTTLDuration: '2 hours',
sessionTTL: new Date('12-6-2020'),
sessionTTLDuration: '',
roles: ['admin'],
requestReason:
'Testing long message format. I am requesting access for the developer role that i will be using to \
commit fixes for our production application. I will need access for the \
rest of the day to complete my changes.',
resolveReason: '',
reviews: [],
reviewers: [
{
name: 'alice',
state: 'PENDING',
},
{
name: 'bob',
state: 'PENDING',
},
],
thresholdNames: ['Default', 'Poplar', 'Admin'],
resources: [],
assumeStartTime: new Date('12-5-2020'),
assumeStartTimeDuration: '24 hours from now',
};
export const requestRoleDenied: AccessRequest = {
id: '3ce23da9-6b85-5fce-9bf3-5fb826120cb2',
state: 'DENIED',
user: 'Sam',
expires: new Date('12-6-2020'),
expiresDuration: '20 hours',
created: new Date('12-2-2020'),
createdDuration: '35 minutes ago',
maxDuration: new Date('12-6-2020'),
maxDurationText: '',
requestTTL: new Date('12-5-2020'),
requestTTLDuration: '1 hour',
sessionTTL: new Date('12-5-2020'),
sessionTTLDuration: '',
roles: ['ruhh', 'admin'],
requestReason: 'Some short request reason',
resolveReason: '',
reviews: [
{
author: 'alice',
createdDuration: '26 hours ago',
state: 'DENIED',
reason: 'Not today',
roles: ['admin', 'developer'],
},
],
reviewers: [
{
name: 'alice',
state: 'DENIED',
},
{
name: 'bob',
state: 'PENDING',
},
],
thresholdNames: ['Default'],
resources: [],
};
export const requestRoleApproved: AccessRequest = {
id: '72de9b90-04fd-5621-a55d-432d9fe56ef2',
state: 'APPROVED',
user: 'Sam',
expires: new Date('12-6-2020'),
expiresDuration: '24 hours',
created: new Date('12-1-2020'),
createdDuration: '2 hours ago',
maxDuration: new Date('12-6-2020'),
maxDurationText: '',
requestTTL: new Date('12-5-2020'),
requestTTLDuration: '2 hours',
sessionTTL: new Date('12-5-2020'),
sessionTTLDuration: '',
roles: ['kaco', 'ziuzzow', 'admin'],
requestReason: '',
resolveReason: '',
reviews: [
{
author: 'alice',
createdDuration: '26 hours ago',
reason:
'Approving for developer role not admin. Admins access is not needed for this request.',
state: 'APPROVED',
roles: ['kaco', 'admin'],
},
{
author: 'test-long-user-name@testing.com',
createdDuration: '1 minute ago',
reason: '',
state: 'APPROVED',
roles: ['admin'],
},
],
reviewers: [
{
name: 'alice',
state: 'APPROVED',
},
{
name: 'bob',
state: 'PENDING',
},
{
name: 'test-long-user-name@testing.com',
state: 'APPROVED',
},
],
thresholdNames: ['Default'],
resources: [],
};
export const requestRoleApprovedWithStartTime: AccessRequest = {
id: '72de9b90-04fd-5621-a55d-432d9fe56ef2',
state: 'APPROVED',
user: 'Sam',
expires: new Date('12-6-2020'),
expiresDuration: '24 hours',
created: new Date('12-1-2020'),
createdDuration: '2 hours ago',
maxDuration: new Date('12-6-2020'),
maxDurationText: '',
requestTTL: new Date('12-5-2020'),
requestTTLDuration: '2 hours',
sessionTTL: new Date('12-5-2020'),
sessionTTLDuration: '',
roles: ['kaco', 'ziuzzow', 'admin'],
requestReason: '',
resolveReason: '',
reviews: [
{
author: 'test-long-user-name@testing.com',
createdDuration: '1 minute ago',
reason: '',
state: 'APPROVED',
roles: ['admin'],
},
],
reviewers: [
{
name: 'alice',
state: 'APPROVED',
},
],
thresholdNames: ['Default'],
resources: [],
assumeStartTime: new Date('12-6-9999'),
assumeStartTimeDuration: '24 hours from now',
};
export const requestRolePromoted: AccessRequest = {
id: '72de9b90-04fd-5621-a55d-432d9fe56ef2',
state: 'PROMOTED',
user: 'Sam',
expires: new Date('12-6-2020'),
expiresDuration: '24 hours',
created: new Date('12-1-2020'),
createdDuration: '2 hours ago',
maxDuration: new Date('12-6-2020'),
maxDurationText: '24 hours',
requestTTL: new Date('12-5-2020'),
requestTTLDuration: '',
sessionTTL: new Date('12-5-2020'),
sessionTTLDuration: '',
roles: ['kaco', 'ziuzzow', 'admin'],
requestReason: '',
resolveReason: '',
reviews: [
{
author: 'george.washington.first.president@testing.com',
createdDuration: '1 minute ago',
reason: '',
promotedAccessListTitle: 'Design Team',
roles: ['admin'],
state: 'PROMOTED',
},
],
reviewers: [
{
name: 'george.washington.first.president@testing.com',
state: 'PROMOTED',
},
],
thresholdNames: ['Default'],
resources: [],
promotedAccessListTitle: 'Design Team',
};
export const requestRoleEmpty: AccessRequest = {
...requestRoleApproved,
reviews: [],
reviewers: [],
roles: ['empty-values'],
id: 'ffc11a95-e8af-581c-ba82-47c429c841e8',
};
export const requests = [
requestRolePending,
requestRoleDenied,
requestRoleApproved,
];
@@ -0,0 +1,95 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { ResourceIdKind } from 'teleport/services/agents';
export type RequestState =
| 'NONE'
| 'PENDING'
| 'APPROVED'
| 'DENIED'
| 'APPLIED'
| 'PROMOTED'
| '';
export interface AccessRequest {
id: string;
state: RequestState;
user: string;
expires: Date;
expiresDuration: string;
created: Date;
createdDuration: string;
maxDuration: Date;
maxDurationText: string;
requestTTL: Date;
requestTTLDuration: string;
sessionTTL: Date;
sessionTTLDuration: string;
roles: string[];
requestReason: string;
resolveReason: string;
reviewers: AccessRequestReviewer[];
reviews: AccessRequestReview[];
thresholdNames: string[];
resources: Resource[];
promotedAccessListTitle?: string;
assumeStartTime?: Date;
assumeStartTimeDuration?: string;
}
export interface AccessRequestReview {
author: string;
roles: string[];
state: RequestState;
reason: string;
createdDuration: string;
promotedAccessListTitle?: string;
assumeStartTime?: Date;
}
export interface AccessRequestReviewer {
name: string;
state: RequestState;
}
export type Resource = {
id: ResourceId;
details?: ResourceDetails;
};
// ResourceID is a unique identifier for a teleport resource.
export type ResourceId = {
// kind is the resource (agent) kind.
kind: ResourceIdKind;
// name is the name of the specific resource.
name: string;
// clusterName is the name of cluster.
clusterName: string;
// subResourceName is the sub resource belonging to resource "name" the user
// is allowed to access.
subResourceName?: string;
};
// ResourceDetails holds optional details for a resource.
export type ResourceDetails = {
// hostname is the resource hostname.
// TODO(mdwn): Remove hostname as it's no longer used.
hostname?: string;
friendlyName?: string;
};
@@ -0,0 +1,20 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export * from './accessRequests';
export * from './makeAccessRequest';
@@ -0,0 +1,150 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { formatDistanceStrict } from 'date-fns';
import {
AccessRequest,
AccessRequestReview,
AccessRequestReviewer,
} from './accessRequests';
// TODO(gzdunek): This function should live in the Web UI.
// As of now, it is also used by Connect,
// to allow it produce the full `AccessRequest` object.
// There are two problems with it:
// 1. In Connect we receive a typed gRPC response,
// so we don't need all these manual types conversions.
// 2. Many of the `AccessRequest` properties could be as well calculated
// in places where they are needed, instead of made in `makeAccessRequest`.
// For example, `requestTTLDuration`.
export function makeAccessRequest(json?): AccessRequest {
json = json || {};
const reviews = makeReviews(json.reviews);
const reviewers = makeReviewers(json.suggestedReviewers, reviews);
return {
id: json.id,
state: json.state,
user: json.user,
expires: new Date(json.expires),
expiresDuration: getDurationText(json.expires),
created: new Date(json.created),
createdDuration: getDurationAgoText(json.created),
// maxDuration can be null if talking with an older auth (before v13.3)
maxDuration: json.maxDuration ? new Date(json.maxDuration) : null,
maxDurationText: getDurationText(json.maxDuration),
requestTTL: json.requestTTL,
requestTTLDuration: getDurationText(json.requestTTL),
// sessionTTL can be null if talking with an older auth (before v13.3)
sessionTTL: json.sessionTTL ? new Date(json.sessionTTL) : null,
sessionTTLDuration: getDurationText(json.sessionTTL),
roles: json.roles || [],
resolveReason: json.resolveReason,
requestReason: json.requestReason,
reviews,
reviewers,
thresholdNames: json.thresholdNames || [],
resources: json.resources || [],
promotedAccessListTitle: json.promotedAccessListTitle,
// assumeStartTime can be null because it's an optional field
// to request.
assumeStartTime: json.assumeStartTime
? new Date(json.assumeStartTime)
: null,
assumeStartTimeDuration: getAssumeStartDurationText(json.assumeStartTime),
};
}
function makeReviews(jsonReviews): AccessRequestReview[] {
jsonReviews = jsonReviews || [];
return jsonReviews.map(review => ({
author: review.author,
state: review.state,
reason: review.reason,
roles: review.roles || [],
createdDuration: getDurationAgoText(review.created),
promotedAccessListTitle: review.promotedAccessListTitle,
assumeStartTime: review.assumeStartTime
? new Date(review.assumeStartTime)
: null,
}));
}
function makeReviewers(jsonSuggestedReviewers, reviews: AccessRequestReview[]) {
jsonSuggestedReviewers = jsonSuggestedReviewers || [];
let allReviewers: AccessRequestReviewer[] = jsonSuggestedReviewers.map(
name =>
({
name,
state: 'PENDING',
}) as AccessRequestReviewer
);
// The reviewers in reviews list, may not be a part of the suggested reviewers list
// b/c any user with permission can review a request.
reviews.forEach(review => {
const index = jsonSuggestedReviewers.indexOf(review.author);
if (index > -1) {
allReviewers[index].state = review.state;
} else {
allReviewers = [
...allReviewers,
{ name: review.author, state: review.state },
];
}
});
return allReviewers;
}
function getDurationText(date: Date) {
if (!date) {
return '';
}
const duration = formatDistanceStrict(new Date(), new Date(date));
return duration;
}
function getDurationAgoText(date: Date) {
return date
? formatDistanceStrict(new Date(date), new Date(), { addSuffix: true })
: '';
}
function getAssumeStartDurationText(date: Date) {
if (canAssumeNow(date)) {
return 'now';
}
return `${getDurationText(date)} from now`;
}
export function canAssumeNow(date: Date) {
if (!date) {
return true;
}
return Date.now() >= new Date(date).getTime();
}
@@ -0,0 +1,162 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Transition } from 'react-transition-group';
import { Box, Flex, ButtonPrimary, ButtonText, Text, ButtonIcon } from 'design';
import { ChevronDown } from 'design/Icon';
import { pluralize } from 'shared/utils/text';
import { RequestCheckout } from 'shared/components/AccessRequests/NewRequest';
import useAccessRequestCheckout from './useAccessRequestCheckout';
import { AssumedRolesBar } from './AssumedRolesBar';
export function RequestCheckoutSuccess({
onClose,
reset,
}: RequestCheckoutSuccessProps) {
return (
<Box textAlign="center">
<ButtonPrimary
mt={5}
mb={3}
width="100%"
size="large"
onClick={() => {
reset();
onClose();
}}
>
Back to Listings
</ButtonPrimary>
<ButtonText
onClick={() => {
onClose();
}}
>
Make Another Request
</ButtonText>
</Box>
);
}
type RequestCheckoutSuccessProps = {
onClose: () => void;
reset: () => void;
};
export function AccessRequestCheckout() {
const {
showCheckout,
isCollapsed,
collapseBar,
setHasExited,
createRequestAttempt,
toggleResource,
selectedResourceRequestRoles,
createRequest,
resourceRequestRoles,
fetchResourceRolesAttempt,
setSelectedResourceRequestRoles,
clearCreateAttempt,
data,
suggestedReviewers,
selectedReviewers,
setSelectedReviewers,
assumedRequests,
requestedCount,
goToRequestsList: reset, // have to pass through RequestCheckout because works differently on web
setShowCheckout,
maxDuration,
setMaxDuration,
dryRunResponse,
requestTTL,
setRequestTTL,
} = useAccessRequestCheckout();
return (
<>
{data.length > 0 && !isCollapsed() && (
<Box
p={3}
css={`
border-top: 1px solid
${props => props.theme.colors.spotBackground[1]};
`}
>
<Flex justifyContent="space-between" alignItems="center">
<Text typography="h4" bold>
{data.length} {pluralize(data.length, 'Resource')} Selected
</Text>
<Flex gap={3}>
<ButtonPrimary onClick={() => setShowCheckout(!showCheckout)}>
Proceed to Request
</ButtonPrimary>
<ButtonIcon onClick={collapseBar}>
<ChevronDown size="medium" />
</ButtonIcon>
</Flex>
</Flex>
</Box>
)}
{assumedRequests.map(request => (
<AssumedRolesBar key={request.id} assumedRolesRequest={request} />
))}
<Transition
in={showCheckout}
onEntered={() => setHasExited(false)}
onExited={() => setHasExited(true)}
timeout={300}
mountOnEnter
unmountOnExit
>
{transitionState => (
<RequestCheckout
toggleResource={toggleResource}
onClose={() => setShowCheckout(false)}
transitionState={transitionState}
SuccessComponent={RequestCheckoutSuccess}
reset={reset}
data={data}
createAttempt={createRequestAttempt}
resourceRequestRoles={resourceRequestRoles}
fetchResourceRequestRolesAttempt={fetchResourceRolesAttempt}
selectedResourceRequestRoles={selectedResourceRequestRoles}
setSelectedResourceRequestRoles={setSelectedResourceRequestRoles}
createRequest={createRequest}
clearAttempt={clearCreateAttempt}
reviewers={suggestedReviewers}
selectedReviewers={selectedReviewers}
setSelectedReviewers={setSelectedReviewers}
requireReason={false}
numRequestedResources={requestedCount}
isResourceRequest={data[0]?.kind !== 'role'}
fetchStatus={'loaded'}
dryRunResponse={dryRunResponse}
maxDuration={maxDuration}
setMaxDuration={setMaxDuration}
requestTTL={requestTTL}
setRequestTTL={setRequestTTL}
/>
)}
</Transition>
</>
);
}
@@ -0,0 +1,112 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import styled from 'styled-components';
import { Box, Flex, Text } from 'design';
import { pluralize } from 'shared/utils/text';
import { AssumedRequest } from 'teleterm/services/tshd/types';
import { useAssumedRolesBar } from './useAssumedRolesBar';
export function AssumedRolesBar({ assumedRolesRequest }: Props) {
const {
duration,
assumedRoles,
dropRequest,
dropRequestAttempt,
hasExpired,
} = useAssumedRolesBar(assumedRolesRequest);
const roleText = pluralize(assumedRoles.length, 'role');
const durationText = `${roleText} assumed, expires in ${duration}`;
const hasExpiredText =
assumedRoles.length > 1 ? 'have expired' : 'has expired';
const expirationText = `${roleText} ${hasExpiredText}`;
const assumedRolesText = assumedRoles.join(', ');
return (
<Box
px={3}
py={2}
bg="brand"
borderTop={1}
css={`
border-color: ${props => props.theme.colors.spotBackground[1]};
`}
>
<Flex justifyContent="space-between" alignItems="center">
<Flex alignItems="center">
<Box
borderRadius="20px"
py={1}
px={3}
mr={2}
color="text.primary"
bg="text.primaryInverse"
style={{
fontWeight: '500',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '200px',
whiteSpace: 'nowrap',
}}
title={assumedRolesText}
>
{assumedRolesText}
</Box>
<Text typography="body" color="text.primaryInverse">
{hasExpired ? expirationText : durationText}
</Text>
</Flex>
<StyledButtonLink
onClick={dropRequest}
disabled={dropRequestAttempt.status === 'processing'}
>
Drop Request
</StyledButtonLink>
</Flex>
</Box>
);
}
type Props = {
assumedRolesRequest: AssumedRequest;
};
const StyledButtonLink = styled.button`
color: ${props => props.theme.colors.text.primaryInverse};
background: none;
text-decoration: underline;
text-transform: none;
padding: 8px;
outline: none;
border: none;
border-radius: 4px;
font-family: inherit;
&:hover,
&:focus {
background: ${props => props.theme.colors.spotBackground[1]};
cursor: pointer;
}
&:disabled {
background: ${props => props.theme.colors.spotBackground[0]};
color: ${props => props.theme.colors.text.disabled};
}
`;
@@ -0,0 +1,19 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { AccessRequestCheckout } from './AccessRequestCheckout';
@@ -0,0 +1,343 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState, useEffect } from 'react';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import useAttempt from 'shared/hooks/useAttemptNext';
import {
ReviewerOption,
getDryRunMaxDuration,
} from 'shared/components/AccessRequests/NewRequest';
import { CreateRequest } from 'shared/components/AccessRequests/Shared/types';
import { Option } from 'shared/components/Select';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { PendingAccessRequest } from 'teleterm/ui/services/workspacesService';
import { retryWithRelogin } from 'teleterm/ui/utils';
import {
CreateAccessRequestRequest,
AccessRequest as TeletermAccessRequest,
} from 'teleterm/services/tshd/types';
import { ResourceKind } from '../DocumentAccessRequests/NewRequest/useNewRequest';
import { makeUiAccessRequest } from '../DocumentAccessRequests/useAccessRequests';
import type { AccessRequest } from 'shared/services/accessRequests';
export default function useAccessRequestCheckout() {
const ctx = useAppContext();
ctx.workspacesService.useState();
ctx.clustersService.useState();
const clusterUri =
ctx.workspacesService?.getActiveWorkspace()?.localClusterUri;
const rootClusterUri = ctx.workspacesService?.getRootClusterUri();
// Contains max time options (to calculate max duration and requestTTL options)
// and suggested reviewers that were available both statically (from roles)
// and dynamically (from access lists).
const [dryRunResponse, setDryRunResponse] = useState<AccessRequest | null>();
// The reviewers defined in the users roles (static) and access list owners
// (dynamic).
const [suggestedReviewers, setSuggestedReviewers] = useState<string[]>([]);
// User selected reviewers from suggested reviewers options and/or
// any other reviewers they manually added.
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerOption[]>(
[]
);
// Access request lifetime upon creation.
// Duration countdown starts from access request creation.
const [maxDuration, setMaxDuration] = useState<Option<number>>();
// How long the request can be in a PENDING state before it expires.
const [requestTTL, setRequestTTL] = useState<Option<number>>();
const [showCheckout, setShowCheckout] = useState(false);
const [hasExited, setHasExited] = useState(false);
const [requestedCount, setRequestedCount] = useState(0);
const [resourceRequestRoles, setResourceRequestRoles] = useState<string[]>(
[]
);
const [selectedResourceRequestRoles, setSelectedResourceRequestRoles] =
useState<string[]>([]);
const { attempt: createRequestAttempt, setAttempt: setCreateRequestAttempt } =
useAttempt('');
const { attempt: fetchResourceRolesAttempt, run: runFetchResourceRoles } =
useAttempt('success');
const workspaceAccessRequest =
ctx.workspacesService.getActiveWorkspaceAccessRequestsService();
const docService = ctx.workspacesService.getActiveWorkspaceDocumentService();
const pendingAccessRequest =
workspaceAccessRequest?.getPendingAccessRequest();
useEffect(() => {
// Do a new dry run per checkout to get the latest time options
// and latest calculated suggested reviewers.
if (showCheckout) {
performDryRun();
}
}, [showCheckout]);
useEffect(() => {
if (!pendingAccessRequest) {
return;
}
const data = getPendingAccessRequestsPerResource(pendingAccessRequest);
runFetchResourceRoles(() =>
retryWithRelogin(ctx, clusterUri, () =>
ctx.clustersService.getRequestableRoles({
clusterUri: rootClusterUri,
resourceIds: data
.filter(d => d.kind !== 'role')
.map(d => ({
name: d.name,
kind: d.kind,
clusterName: d.clusterName,
subResourceName: '',
})),
})
).then(response => {
setResourceRequestRoles(response.applicableRoles);
setSelectedResourceRequestRoles(response.applicableRoles);
})
);
}, [pendingAccessRequest]);
useEffect(() => {
clearCreateAttempt();
}, [clusterUri]);
useEffect(() => {
if (
!showCheckout &&
hasExited &&
createRequestAttempt.status === 'success'
) {
clearCreateAttempt();
setRequestedCount(0);
setDryRunResponse(null);
}
}, [showCheckout, hasExited, createRequestAttempt.status]);
function getPendingAccessRequestsPerResource(
resourceIds: PendingAccessRequest
) {
const data: {
kind: ResourceKind;
clusterName: string;
id: string;
name: string;
}[] = [];
if (!workspaceAccessRequest) {
return data;
}
const clusterName = ctx.clustersService.findCluster(clusterUri)?.name;
const resourceKeys = Object.keys(resourceIds) as ResourceKind[];
resourceKeys.forEach(kind => {
Object.keys(resourceIds[kind]).forEach(id => {
data.push({ kind, id, name: resourceIds[kind][id], clusterName });
});
});
return data;
}
function isCollapsed() {
if (!workspaceAccessRequest) {
return true;
}
return workspaceAccessRequest.getCollapsed();
}
function toggleResource(
kind: ResourceKind,
resourceId: string,
resourceName: string
) {
workspaceAccessRequest.addOrRemoveResource(kind, resourceId, resourceName);
}
function getAssumedRequests() {
if (!clusterUri) {
return [];
}
const assumed = ctx.clustersService.getAssumedRequests(rootClusterUri);
if (!assumed) {
return [];
}
return Object.values(assumed);
}
/**
* Shared logic used both during dry runs and regular access request creation.
*/
function prepareAndCreateRequest(req: CreateRequest) {
const data = getPendingAccessRequestsPerResource(pendingAccessRequest);
const params: CreateAccessRequestRequest = {
rootClusterUri,
reason: req.reason,
suggestedReviewers: req.suggestedReviewers || [],
dryRun: req.dryRun,
resourceIds: data
.filter(d => d.kind !== 'role')
.map(d => ({
name: d.id,
clusterName: d.clusterName,
kind: d.kind,
subResourceName: '',
})),
roles: data.filter(d => d.kind === 'role').map(d => d.name),
assumeStartTime: req.start && Timestamp.fromDate(req.start),
maxDuration: req.maxDuration && Timestamp.fromDate(req.maxDuration),
requestTtl: req.requestTTL && Timestamp.fromDate(req.requestTTL),
};
// if we have a resource access request, we pass along the selected roles from the checkout
if (params.resourceIds.length > 0) {
params.roles = selectedResourceRequestRoles;
}
setCreateRequestAttempt({ status: 'processing' });
return retryWithRelogin(ctx, clusterUri, () =>
ctx.clustersService.createAccessRequest(params).then(({ response }) => {
return { accessRequest: response.request, requestedCount: data.length };
})
).catch(e => {
setCreateRequestAttempt({ status: 'failed', statusText: e.message });
throw e;
});
}
async function performDryRun() {
let teletermAccessRequest: TeletermAccessRequest;
try {
const { accessRequest } = await prepareAndCreateRequest({
dryRun: true,
maxDuration: getDryRunMaxDuration(),
});
teletermAccessRequest = accessRequest;
} catch {
return;
}
setCreateRequestAttempt({ status: '' });
const accessRequest = makeUiAccessRequest(teletermAccessRequest);
setDryRunResponse(accessRequest);
const reviewers = accessRequest.reviewers.map(r => r.name).sort();
setSuggestedReviewers(reviewers);
// Initially select suggested reviewers for the requestor.
setSelectedReviewers(
reviewers.map(r => ({
value: r,
label: r,
isSelected: true,
}))
);
}
async function createRequest(req: CreateRequest) {
let requestedCount: number;
try {
const response = await prepareAndCreateRequest(req);
requestedCount = response.requestedCount;
} catch {
return;
}
setRequestedCount(requestedCount);
reset();
setCreateRequestAttempt({ status: 'success' });
}
function clearCreateAttempt() {
setCreateRequestAttempt({ status: '', statusText: '' });
}
function collapseBar() {
if (workspaceAccessRequest) {
return workspaceAccessRequest.toggleBar();
}
}
function reset() {
if (workspaceAccessRequest) {
return workspaceAccessRequest.clearPendingAccessRequest();
}
clearCreateAttempt();
}
function goToRequestsList() {
const activeDoc = docService.getActive();
if (activeDoc && activeDoc.kind === 'doc.access_requests') {
docService.update(activeDoc.uri, {
state: 'browsing',
title: 'Access Requests',
});
} else {
const listDoc = docService.createAccessRequestDocument({
clusterUri: rootClusterUri,
state: 'browsing',
});
docService.add(listDoc);
docService.open(listDoc.uri);
}
}
return {
showCheckout,
isCollapsed,
assumedRequests: getAssumedRequests(),
toggleResource,
data: getPendingAccessRequestsPerResource(pendingAccessRequest),
createRequest,
reset,
setHasExited,
goToRequestsList,
requestedCount,
clearCreateAttempt,
clusterUri,
selectedResourceRequestRoles,
setSelectedResourceRequestRoles,
resourceRequestRoles,
rootClusterUri,
fetchResourceRolesAttempt,
createRequestAttempt,
collapseBar,
setShowCheckout,
suggestedReviewers,
selectedReviewers,
setSelectedReviewers,
dryRunResponse,
maxDuration,
setMaxDuration,
requestTTL,
setRequestTTL,
};
}
@@ -0,0 +1,148 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState, useCallback } from 'react';
import {
intervalToDuration,
isBefore,
secondsToMilliseconds,
formatDuration,
Duration,
} from 'date-fns';
import { useAsync } from 'shared/hooks/useAsync';
import { useInterval } from 'shared/hooks';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { AssumedRequest } from 'teleterm/services/tshd/types';
export function useAssumedRolesBar(assumedRequest: AssumedRequest) {
const ctx = useAppContext();
const rootClusterUri = ctx.workspacesService?.getRootClusterUri();
const [duration, setDuration] = useState<Duration>(() =>
getDurationFromNow({
end: assumedRequest.expires,
})
);
const [interval, setInterval] = useState<number | null>(
getRefreshInterval(duration)
);
const [dropRequestAttempt, dropRequest] = useAsync(() => {
return retryWithRelogin(
ctx,
rootClusterUri,
() =>
// only passing the 'unassumed' role id as the backend will
// persist any other access requests currently available that
// are not present in the dropIds array
ctx.clustersService.assumeRole(rootClusterUri, [], [assumedRequest.id])
// TODO(gzdunek): We should refresh the resources,
// the same as after assuming a role in `useAssumeAccess`.
// Unfortunately, we can't do this because we don't have access to `ResourcesContext`.
// Consider moving it into `ResourcesService`.
).catch(err => {
ctx.notificationsService.notifyError({
title: 'Could not switch back the role',
description: err.message,
});
});
});
const updateDurationAndInterval = useCallback(() => {
const calculatedDuration = getDurationFromNow({
end: assumedRequest.expires,
});
setDuration(calculatedDuration);
if (hasExpired(calculatedDuration)) {
setInterval(null); // stop updates
} else {
setInterval(getRefreshInterval(calculatedDuration));
}
}, [assumedRequest.expires]);
useInterval(updateDurationAndInterval, interval);
return {
duration: getFormattedDuration(duration),
hasExpired: hasExpired(duration),
dropRequest,
dropRequestAttempt,
assumedRoles: assumedRequest.roles,
};
}
//TODO(gzdunek): use it in web too
function getFormattedDuration(duration: Duration): string {
const format = ['years', 'months', 'weeks', 'days', 'hours', 'minutes'];
if (isLessThan10Minutes(duration)) {
format.push('seconds');
}
return formatDuration(duration, {
format,
});
}
function hasExpired(duration: Duration): boolean {
return (
!duration.years &&
!duration.months &&
!duration.weeks &&
!duration.days &&
!duration.hours &&
!duration.minutes &&
!duration.seconds
);
}
const HIGH_REFRESH_RATE = secondsToMilliseconds(1);
const LOW_REFRESH_RATE = secondsToMilliseconds(15);
function getRefreshInterval(duration: Duration): number {
return isLessThan10Minutes(duration) ? HIGH_REFRESH_RATE : LOW_REFRESH_RATE;
}
function getDurationFromNow(params: { end: Date }): Duration {
const now = new Date();
if (isBefore(params.end, now)) {
return {}; // all values are empty
}
return intervalToDuration({
start: now,
end: params.end,
});
}
function isLessThan10Minutes(duration: Duration) {
return (
!duration.years &&
!duration.months &&
!duration.weeks &&
!duration.days &&
!duration.hours &&
duration.minutes < 10
);
}
@@ -0,0 +1,95 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Attempt } from 'shared/hooks/useAsync';
import { AccessRequest } from 'shared/services/accessRequests';
import { RequestFlags } from 'shared/components/AccessRequests/ReviewRequests';
import Document from 'teleterm/ui/Document';
import * as types from 'teleterm/ui/services/workspacesService';
import { useAssumeAccess } from './useAssumeAccess';
import useAccessRequests from './useAccessRequests';
import { RequestList } from './RequestList/RequestList';
import { ReviewAccessRequest } from './ReviewAccessRequest';
import { NewRequest } from './NewRequest';
export function DocumentAccessRequests(props: DocumentProps) {
const state = useAccessRequests(props.doc);
const { assumeRole, assumeRoleAttempt, assumeAccessList } = useAssumeAccess();
return (
<Document doc={props.doc} visible={props.visible}>
<DocumentAccessRequestsViews
{...state}
assumeRole={assumeRole}
assumeRoleAttempt={assumeRoleAttempt}
assumeAccessList={assumeAccessList}
/>
</Document>
);
}
export function DocumentAccessRequestsViews({
accessRequests,
attempt,
doc,
assumeRole,
assumeRoleAttempt,
getRequests,
goBack,
onViewRequest,
assumeAccessList,
getFlags,
}: DocumentAccessRequestsProps & {
assumeRole(requestId: string): void;
assumeRoleAttempt: Attempt<void>;
assumeAccessList(): void;
getFlags(accessRequest: AccessRequest): RequestFlags;
}) {
if (doc.state === 'creating') {
return <NewRequest />;
}
if (doc.state === 'reviewing') {
return <ReviewAccessRequest requestId={doc.requestId} goBack={goBack} />;
}
return (
<RequestList
assumeRole={accessRequest => assumeRole(accessRequest.id)}
attempt={attempt}
requests={accessRequests}
getFlags={getFlags}
getRequests={getRequests}
viewRequest={(id: string) => onViewRequest(id)}
assumeRoleAttempt={assumeRoleAttempt}
assumeAccessList={assumeAccessList}
/>
);
}
export type DocumentAccessRequestsProps = ReturnType<typeof useAccessRequests>;
type DocumentProps = {
visible: boolean;
doc: types.DocumentAccessRequests;
};
@@ -0,0 +1,76 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { Text, ButtonIcon, ButtonWarning } from 'design';
import DialogConfirmation, {
DialogContent,
DialogFooter,
DialogHeader,
} from 'design/DialogConfirmation';
import { Cross } from 'design/Icon';
const updateSelectedResourceWarning =
'Resource Access Request cannot be combined with Role Access Request. The current items will be cleared. Are you sure you want to continue?';
export default function ChangeResourceDialog({
toResource,
onClose,
onConfirm,
}: Props) {
return (
<DialogConfirmation
open={!!toResource}
onClose={onClose}
dialogCss={() => ({
maxWidth: '400px',
width: '100%',
})}
>
<DialogHeader justifyContent="space-between" mb={0}>
<Text typography="h5" bold style={{ whiteSpace: 'nowrap' }}>
Remove selected resources?
</Text>
<ButtonIcon onClick={onClose} color="text.slightlyMuted">
<Cross size="small" />
</ButtonIcon>
</DialogHeader>
<DialogContent mb={4}>
<Text color="text.slightlyMuted" typography="body1">
{updateSelectedResourceWarning}
</Text>
</DialogContent>
<DialogFooter>
<ButtonWarning
size="large"
block={true}
onClick={() => onConfirm(toResource)}
>
Confirm
</ButtonWarning>
</DialogFooter>
</DialogConfirmation>
);
}
type Props = {
toResource: string;
onClose: () => void;
onConfirm: (resource: string) => void;
};
@@ -0,0 +1,212 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { Alert, Box, Flex } from 'design';
import { space, width } from 'design/system';
import { SearchPagination, SearchPanel } from 'shared/components/Search';
import { ResourceList } from 'shared/components/AccessRequests/NewRequest';
import useNewRequest, { ResourceKind } from './useNewRequest';
import ChangeResourceDialog from './ChangeResourceDialog';
const agentOptions: ResourceOption[] = [
{ value: 'role', label: 'Roles' },
{
value: 'node',
label: 'Servers',
},
{
value: 'app',
label: 'Apps',
},
{
value: 'db',
label: 'Databases',
},
{
value: 'kube_cluster',
label: 'Kubes',
},
];
export function NewRequest() {
const {
attempt,
agentFilter,
pageCount,
updateQuery,
updateSearch,
selectedResource,
customSort,
handleConfirmChangeResource,
toResource,
setToResource,
fetchStatus,
onAgentLabelClick,
addedResources,
addOrRemoveResource,
updateResourceKind,
prevPage,
requestableRoles,
isLeafCluster,
nextPage,
agents,
} = useNewRequest();
function handleUpdateSelectedResource(kind: ResourceKind) {
const numAddedAgents =
Object.keys(addedResources.node).length +
Object.keys(addedResources.db).length +
Object.keys(addedResources.app).length +
Object.keys(addedResources.kube_cluster).length +
Object.keys(addedResources.windows_desktop).length;
const numAddedRoles = Object.keys(addedResources.role).length;
if (
(kind === 'role' && numAddedAgents > 0) ||
(kind !== 'role' && numAddedRoles > 0)
) {
setToResource(kind);
} else {
updateResourceKind(kind);
}
}
// Leaf clusters do not allow role requests, so we do not show that option in the UI if leaf
const filteredAgentOptions = useMemo(
() =>
agentOptions.filter(agent =>
isLeafCluster ? agent.value !== 'role' : agent
),
[isLeafCluster]
);
const isRoleList = selectedResource === 'role';
return (
<Layout mx="auto" px={5} pt={3} height="100%" flexDirection="column">
{attempt.status === 'failed' && (
<Alert kind="danger" children={attempt.statusText} />
)}
<ChangeResourceDialog
toResource={toResource}
onClose={() => setToResource(null)}
onConfirm={handleConfirmChangeResource}
/>
<StyledMain>
<Flex mt={3} mb={3}>
{filteredAgentOptions.map(agent => (
<StyledNavButton
key={agent.value}
mr={6}
p={1}
active={selectedResource === agent.value}
onClick={() => handleUpdateSelectedResource(agent.value)}
>
{agent.label}
</StyledNavButton>
))}
</Flex>
{/* roles use client-side search */}
{!isRoleList && (
<SearchPanel
updateQuery={updateQuery}
updateSearch={updateSearch}
pageIndicators={pageCount}
filter={agentFilter}
showSearchBar={true}
disableSearch={fetchStatus === 'loading'}
/>
)}
<ResourceList
agents={agents}
selectedResource={selectedResource}
customSort={customSort}
onLabelClick={onAgentLabelClick}
addedResources={addedResources}
addOrRemoveResource={addOrRemoveResource}
requestableRoles={requestableRoles}
disableRows={fetchStatus === 'loading'}
/>
{!isRoleList && (
<SearchPagination
nextPage={fetchStatus === 'loading' ? null : nextPage}
prevPage={fetchStatus === 'loading' ? null : prevPage}
/>
)}
</StyledMain>
</Layout>
);
}
const Layout = styled(Box)`
flex-direction: column;
display: flex;
flex: 1;
max-width: 1248px;
::after {
content: ' ';
padding-bottom: 24px;
}
`;
const StyledNavButton = styled.button(props => {
return {
color: props.active
? props.theme.colors.text.main
: props.theme.colors.text.slightlyMuted,
cursor: 'pointer',
display: 'inline-flex',
fontSize: '14px',
position: 'relative',
padding: '0',
marginRight: '24px',
textDecoration: 'none',
fontWeight: props.active ? 700 : 400,
outline: 'inherit',
border: 'none',
backgroundColor: 'inherit',
flexShrink: '0',
borderRadius: '4px',
fontFamily: 'inherit',
'&:hover, &:focus': {
background: props.theme.colors.spotBackground[0],
},
...space(props),
...width(props),
};
});
const StyledMain = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
type ResourceOption = {
value: ResourceKind;
label: string;
};
@@ -0,0 +1,19 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { NewRequest } from './NewRequest';
@@ -0,0 +1,339 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState, useEffect, useCallback } from 'react';
import { FetchStatus, SortType } from 'design/DataTable/types';
import useAttempt from 'shared/hooks/useAttemptNext';
import { makeAdvancedSearchQueryForLabel } from 'shared/utils/advancedSearchLabelQuery';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import {
makeDatabase,
makeServer,
makeKube,
makeApp,
} from 'teleterm/ui/services/clusters';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
import {
GetResourcesParams,
App as tshdApp,
} from 'teleterm/services/tshd/types';
import { routing } from 'teleterm/ui/uri';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
import type {
ResourceLabel,
ResourceFilter as WeakAgentFilter,
ResourcesResponse,
ResourceIdKind,
UnifiedResource,
} from 'teleport/services/agents';
import type * as teleportApps from 'teleport/services/apps';
const pageSize = 10;
type AgentFilter = WeakAgentFilter & { sort: SortType };
export default function useNewRequest() {
const ctx = useAppContext();
const { accessRequestsService, localClusterUri: clusterUri } =
useWorkspaceContext();
const loggedInUser = useWorkspaceLoggedInUser();
const isLeafCluster = routing.isLeafCluster(clusterUri);
const { attempt, setAttempt } = useAttempt('processing');
const [fetchStatus, setFetchStatus] = useState<FetchStatus>('');
const [fetchedData, setFetchedData] = useState<
ResourcesResponse<UnifiedResource>
>(getEmptyFetchedDataState());
const requestableRoles = loggedInUser?.requestableRoles || [];
const [selectedResource, setSelectedResource] =
useState<ResourceKind>('node');
const [agentFilter, setAgentFilter] = useState<AgentFilter>({
sort: getDefaultSort(selectedResource),
});
const addedResources = accessRequestsService.getPendingAccessRequest();
const [page, setPage] = useState<Page>({ keys: [], index: 0 });
const [toResource, setToResource] = useState<string | null>(null);
const retry = <T>(action: () => Promise<T>) =>
retryWithRelogin(ctx, clusterUri, action);
function makeAgent(source) {
switch (selectedResource) {
case 'node':
return makeServer(source);
case 'db':
return makeDatabase(source);
case 'kube_cluster':
return makeKube(source);
case 'app': {
const tshdApp: tshdApp = source;
const app: Pick<
teleportApps.App,
'name' | 'labels' | 'description' | 'userGroups' | 'addrWithProtocol'
> = {
...makeApp(source),
description: tshdApp.desc,
//TODO(gzdunek): Enable requesting apps via user groups in Connect.
// To make this work, we need
// to fetch user groups while fetching the apps
// and then return them for appropriate resources.
// See how it was done in web/apps.go
//
// Additionally, to make this feature complete,
// I think we should also add a tab for requesting the user groups.
// For that, we would have to add a new RPC that lists them.
//
// https://github.com/gravitational/teleport.e/issues/3162
userGroups: [],
};
return app;
}
default:
return source;
}
}
function updateSort(sort: SortType) {
setAgentFilter({ ...agentFilter, sort });
}
function updateSearch(search: string) {
setAgentFilter({ ...agentFilter, query: '', search });
}
function updateQuery(query: string) {
setAgentFilter({ ...agentFilter, search: '', query });
}
function getFetchCallback(params: GetResourcesParams) {
switch (selectedResource) {
case 'node':
return retry(() => ctx.resourcesService.fetchServers(params));
case 'db':
return retry(() => ctx.resourcesService.fetchDatabases(params));
case 'kube_cluster':
return retry(() => ctx.resourcesService.fetchKubes(params));
case 'app':
return retry(() => ctx.resourcesService.fetchApps(params));
default: {
throw new Error(`Fetch not implemented for: ${selectedResource}`);
}
}
}
const fetch = useCallback(async () => {
if (selectedResource !== 'role') {
try {
setFetchStatus('loading');
const data = await getFetchCallback({
clusterUri,
...agentFilter,
limit: pageSize,
searchAsRoles: 'yes',
});
setFetchedData({
agents: data.agents.map(makeAgent),
startKey: data.startKey,
totalCount: data.totalCount,
});
setPage({
keys: ['', data.startKey],
index: 0,
});
setAttempt({ status: 'success' });
setFetchStatus('');
} catch (err) {
setAttempt({ status: 'failed', statusText: err.message });
setFetchStatus('');
}
}
}, [agentFilter, clusterUri, selectedResource]);
useEffect(() => {
fetch();
}, [fetch]);
function onAgentLabelClick(label: ResourceLabel) {
const query = makeAdvancedSearchQueryForLabel(label, agentFilter);
setAgentFilter({ ...agentFilter, search: '', query });
}
function updateResourceKind(kind: ResourceKind) {
setFetchedData(getEmptyFetchedDataState());
setSelectedResource(kind);
setAgentFilter({
sort: getDefaultSort(kind),
search: '',
query: '',
});
}
function handleConfirmChangeResource(kind: ResourceKind) {
accessRequestsService.clearPendingAccessRequest();
updateResourceKind(kind);
setToResource(null);
}
function addOrRemoveResource(
kind: ResourceKind,
resourceId: string,
resourceName?: string
) {
accessRequestsService.addOrRemoveResource(kind, resourceId, resourceName);
}
async function fetchNext() {
setFetchStatus('loading');
try {
const data = await getFetchCallback({
clusterUri,
...agentFilter,
limit: pageSize,
searchAsRoles: 'yes',
startKey: page.keys[page.index + 1],
});
setFetchedData({
...fetchedData,
agents: data.agents.map(makeAgent),
startKey: data.startKey,
});
setPage({
keys: [...page.keys, data.startKey],
index: page.index + 1,
});
setAttempt({ status: 'success' });
setFetchStatus('');
} catch (err) {
setAttempt({ status: 'failed', statusText: err.message });
setFetchStatus('');
}
}
async function fetchPrev() {
setFetchStatus('loading');
try {
const data = await getFetchCallback({
clusterUri,
...agentFilter,
limit: pageSize,
searchAsRoles: 'yes',
startKey: page.keys[page.index - 1],
});
setFetchedData({
...fetchedData,
agents: data.agents.map(makeAgent),
startKey: data.startKey,
});
setPage({
keys: page.keys.slice(0, -1),
index: page.index - 1,
});
setAttempt({ status: 'success' });
setFetchStatus('');
} catch (err) {
setFetchStatus('');
setAttempt({ status: 'failed', statusText: err.message });
}
}
// Calculate counts for our resource list.
let fromPage = 0;
let toPage = 0;
let totalCount = 0;
if (selectedResource !== 'role' && fetchedData.totalCount) {
fromPage = page.index * pageSize + 1;
toPage = fromPage + fetchedData.agents.length - 1;
totalCount = fetchedData.totalCount;
} else if (selectedResource === 'role' && requestableRoles.length > 0) {
fromPage = 1;
toPage = requestableRoles.length;
totalCount = requestableRoles.length;
}
return {
agents: fetchedData.agents,
agentFilter,
updateSort,
attempt,
isLeafCluster,
fetchStatus,
updateQuery,
updateSearch,
toResource,
handleConfirmChangeResource,
setToResource,
onAgentLabelClick,
selectedResource,
updateResourceKind,
addedResources,
addOrRemoveResource,
pageCount: {
to: toPage,
from: fromPage,
total: totalCount,
},
customSort: {
dir: agentFilter.sort?.dir,
fieldName: agentFilter.sort?.fieldName,
onSort: updateSort,
},
nextPage: page.keys[page.index + 1] ? fetchNext : null,
prevPage: page.index > 0 ? fetchPrev : null,
requestableRoles,
};
}
function getEmptyFetchedDataState() {
return {
agents: [],
startKey: '',
totalCount: 0,
};
}
// Page keeps track of our current agent list
// start keys and current position.
type Page = {
// keys are the list of start keys collected from
// each page fetch.
keys: string[];
// index refers to the current index the page
// is at in the list of keys.
index: number;
};
function getDefaultSort(kind: ResourceKind): SortType {
if (kind === 'node') {
return { fieldName: 'hostname', dir: 'ASC' };
}
return { fieldName: 'name', dir: 'ASC' };
}
export type ResourceKind = ResourceIdKind | 'role';
export type State = ReturnType<typeof useNewRequest>;
@@ -0,0 +1,116 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from 'design/utils/testing';
import { requestRoleApproved } from 'shared/components/AccessRequests/fixtures';
import { AccessRequest } from 'shared/services/accessRequests';
import { RequestFlags } from 'shared/components/AccessRequests/ReviewRequests';
import { RequestList } from './RequestList';
test('disabled assume button with assume start date', async () => {
// Set system time before the assume start date.
jest.useFakeTimers().setSystemTime(new Date('2024-02-16T02:51:12.70087Z'));
render(
<MemoryRouter>
<RequestList
attempt={{ status: 'success' }}
assumeRole={() => null}
assumeRoleAttempt={{ status: '', data: null, statusText: '' }}
getRequests={() => null}
viewRequest={() => null}
assumeAccessList={() => null}
getFlags={() => flags}
requests={[request]}
/>
</MemoryRouter>
);
const assumeBtn = screen.getByText(/assume roles/i);
expect(assumeBtn).toBeDisabled();
// Mouse over the disabled button, and expect a popup message.
fireEvent.mouseEnter(assumeBtn);
expect(
screen.getByText(/access is not available until the approved time/i)
).toBeInTheDocument();
});
test('enabled assume button with assume start date', () => {
// Set system time as same as assume start time
jest.useFakeTimers().setSystemTime(request.assumeStartTime);
render(
<MemoryRouter>
<RequestList
attempt={{ status: 'success' }}
assumeRole={() => null}
assumeRoleAttempt={{ status: '', data: null, statusText: '' }}
getRequests={() => null}
viewRequest={() => null}
assumeAccessList={() => null}
getFlags={() => flags}
requests={[request]}
/>
</MemoryRouter>
);
const assumeBtn = screen.getByText(/assume roles/i);
expect(assumeBtn).toBeEnabled();
});
test('enabled assume button with no assume start date', () => {
render(
<MemoryRouter>
<RequestList
attempt={{ status: 'success' }}
assumeRole={() => null}
assumeRoleAttempt={{ status: '', data: null, statusText: '' }}
getRequests={() => null}
viewRequest={() => null}
assumeAccessList={() => null}
getFlags={() => flags}
requests={[
{ ...request, assumeStartTime: null, assumeStartTimeDuration: '' },
]}
/>
</MemoryRouter>
);
const assumeBtn = screen.getByText(/assume roles/i);
expect(assumeBtn).toBeEnabled();
});
const request: AccessRequest = {
...requestRoleApproved,
assumeStartTime: new Date('2024-02-17T02:51:12.70087Z'),
assumeStartTimeDuration: '24 hours from now',
};
const flags: RequestFlags = {
canAssume: true,
isAssumed: false,
ownRequest: true,
isPromoted: false,
canReview: true,
canDelete: true,
};
@@ -0,0 +1,270 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import styled from 'styled-components';
import { Label, Alert, ButtonBorder, Flex, ButtonPrimary, Box } from 'design';
import Table, { Cell } from 'design/DataTable';
import { Attempt } from 'shared/hooks/useAttemptNext';
import { Attempt as AsyncAttempt } from 'shared/hooks/useAsync';
import { AccessRequest, canAssumeNow } from 'shared/services/accessRequests';
import {
renderIdCell,
renderStatusCell,
renderUserCell,
formattedName,
RequestFlags,
} from 'shared/components/AccessRequests/ReviewRequests';
import {
BlockedByStartTimeButton,
ButtonPromotedInfo,
} from 'shared/components/AccessRequests/Shared/Shared';
export function RequestList({
attempt,
requests,
getFlags,
viewRequest,
assumeRoleAttempt,
assumeRole,
getRequests,
assumeAccessList,
}: Props) {
return (
<Layout mx="auto" px={5} pt={3} height="100%">
{attempt.status === 'failed' && (
<Alert kind="danger" children={attempt.statusText} />
)}
{assumeRoleAttempt.status === 'error' && (
<Alert kind="danger" children={assumeRoleAttempt.statusText} />
)}
<Flex justifyContent="end" pb={4}>
<ButtonPrimary
ml={2}
size="small"
onClick={getRequests}
disabled={attempt.status === 'processing'}
>
Refresh
</ButtonPrimary>
</Flex>
<Table
data={requests}
columns={[
{
key: 'id',
headerText: 'Id',
isSortable: true,
render: renderIdCell,
},
{
key: 'state',
headerText: 'Status',
isSortable: true,
render: renderStatusCell,
},
{
key: 'user',
headerText: 'User',
isSortable: true,
render: renderUserCell,
},
{
key: 'roles',
headerText: 'Requested',
render: ({ resources, roles, id }) => (
<RequestedCell resources={resources} roles={roles} id={id} />
),
},
{
key: 'resources',
isNonRender: true,
},
{
key: 'created',
headerText: 'Created',
isSortable: true,
render: ({ createdDuration }) => <Cell>{createdDuration}</Cell>,
},
{
key: 'assumeStartTime',
headerText: 'Available',
isSortable: true,
render: ({ assumeStartTimeDuration }) => (
<Cell>{assumeStartTimeDuration}</Cell>
),
},
{
key: 'expires',
headerText: 'Expires',
isSortable: true,
render: ({ requestTTLDuration }) => (
<Cell>{requestTTLDuration}</Cell>
),
},
{
altKey: 'view-btn',
render: request =>
renderActionCell(
request,
getFlags(request),
assumeRole,
assumeRoleAttempt,
viewRequest,
assumeAccessList
),
},
]}
emptyText="No Requests Found"
isSearchable
pagination={{ pageSize: 20 }}
initialSort={{ key: 'created', dir: 'DESC' }}
customSearchMatchers={[requestMatcher]}
/>
</Layout>
);
}
function requestMatcher(
targetValue: any,
searchValue: string,
propName: keyof AccessRequest & string
) {
if (propName === 'roles') {
return targetValue.some((role: string) =>
role.toUpperCase().includes(searchValue)
);
}
if (propName === 'resources') {
return targetValue.some((r: any) =>
Object.keys(r).some(k => r[k].toUpperCase().includes(searchValue))
);
}
}
const renderActionCell = (
request: AccessRequest,
flags: RequestFlags,
assumeRole: (request: AccessRequest) => void,
assumeRoleAttempt: AsyncAttempt<void>,
viewRequest: (id: string) => void,
assumeAccessList: () => void
) => {
let assumeBtn;
if (flags.canAssume) {
if (canAssumeNow(request.assumeStartTime)) {
assumeBtn = (
<ButtonPrimary
size="small"
disabled={
flags.isAssumed || assumeRoleAttempt.status === 'processing'
}
onClick={() => assumeRole(request)}
width="108px"
>
{flags.isAssumed ? 'assumed' : 'assume roles'}
</ButtonPrimary>
);
} else {
assumeBtn = (
<BlockedByStartTimeButton assumeStartTime={request.assumeStartTime} />
);
}
}
return (
<Cell align="right" style={{ whiteSpace: 'nowrap' }}>
<Flex alignItems="center" justifyContent="right" width="184px">
{assumeBtn}
{flags.isPromoted && (
<ButtonPromotedInfo
request={request}
ownRequest={flags.ownRequest}
assumeAccessList={assumeAccessList}
/>
)}
<ButtonBorder
size="small"
ml={3}
onClick={() => viewRequest(request.id)}
>
View
</ButtonBorder>
</Flex>
</Cell>
);
};
const RequestedCell = ({
roles,
resources,
id,
}: Pick<AccessRequest, 'roles' | 'resources' | 'id'>) => {
if (resources?.length > 0) {
return (
<Cell key={id}>
{resources.map((resource, index) => (
<Label
mb="0"
mr="1"
key={`${resource.id.kind}${formattedName(resource)}${index}`}
kind="secondary"
>
{resource.id.kind}: {formattedName(resource)}
</Label>
))}
</Cell>
);
}
return (
<Cell>
{roles.sort().map(role => (
<Label mb="0" mr="1" key={role} kind="secondary">
role: {role}
</Label>
))}
</Cell>
);
};
const Layout = styled(Box)`
flex-direction: column;
display: flex;
flex: 1;
max-width: 1248px;
::after {
content: ' ';
padding-bottom: 24px;
}
`;
type Props = {
attempt: Attempt;
requests: AccessRequest[];
getFlags: (accessRequest: AccessRequest) => RequestFlags;
assumeRole: (request: AccessRequest) => void;
assumeRoleAttempt: AsyncAttempt<void>;
getRequests: () => void;
viewRequest: (requestId: string) => void;
assumeAccessList: () => void;
};
@@ -0,0 +1,133 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import styled from 'styled-components';
import { Text, Flex, Box, Alert } from 'design';
import { ArrowBack } from 'design/Icon';
import { makeEmptyAttempt } from 'shared/hooks/useAsync';
import {
RequestDelete,
RequestView,
} from 'shared/components/AccessRequests/ReviewRequests';
import { useAssumeAccess } from '../useAssumeAccess';
import { useReviewAccessRequest } from './useReviewAccessRequest';
export function ReviewAccessRequest(props: {
requestId: string;
goBack(): void;
}) {
const {
fetchRequestAttempt,
submitReviewAttempt,
submitReview,
deleteDialogOpen,
setDeleteDialogOpen,
deleteRequest,
deleteRequestAttempt,
user,
getFlags,
fetchSuggestedAccessListsAttempt,
} = useReviewAccessRequest(props);
const { assumeRole, assumeRoleAttempt, assumeAccessList } = useAssumeAccess();
function getDialogDelete() {
const hasRequest =
fetchRequestAttempt.status === 'success' ||
submitReviewAttempt.status === 'success';
if (!(deleteDialogOpen && hasRequest)) {
return;
}
const request =
submitReviewAttempt.status === 'success'
? submitReviewAttempt.data
: fetchRequestAttempt.data;
return (
<RequestDelete
deleteRequestAttempt={deleteRequestAttempt}
user={request.user}
roles={request.roles}
requestId={request.id}
requestState={request.state}
onClose={() => setDeleteDialogOpen(false)}
onDelete={deleteRequest}
/>
);
}
return (
<Layout mx="auto" px={5} pt={3} height="100%">
<Header>
<HeaderTitle typography="h3" mb={3}>
<Flex alignItems="center">
<ArrowBack
mr={2}
size="large"
onClick={props.goBack}
style={{ textDecoration: 'none', cursor: 'pointer' }}
/>
<Text>{`Request: ${props.requestId}`}</Text>
</Flex>
</HeaderTitle>
</Header>
{assumeRoleAttempt.status === 'error' && (
<Alert kind="danger" children={assumeRoleAttempt.statusText} />
)}
<RequestView
user={user?.name}
fetchRequestAttempt={fetchRequestAttempt}
getFlags={getFlags}
confirmDelete={false} // never show the embedded request delete
toggleConfirmDelete={() => setDeleteDialogOpen(true)}
submitReview={submitReview}
assumeRole={() => assumeRole(props.requestId)}
assumeRoleAttempt={assumeRoleAttempt}
submitReviewAttempt={submitReviewAttempt}
fetchSuggestedAccessListsAttempt={fetchSuggestedAccessListsAttempt}
assumeAccessList={assumeAccessList}
//TODO(gzdunek): Remove our custom dialog and instead fill the props here.
deleteRequestAttempt={makeEmptyAttempt()}
deleteRequest={() => undefined}
/>
{getDialogDelete()}
</Layout>
);
}
const Header = styled(Flex)`
flex-shrink: 0;
border-bottom: 1px solid ${props => props.theme.colors.spotBackground[0]};
height: 56px;
margin-bottom: 24px;
`;
const HeaderTitle = styled(Text)`
white-space: nowrap;
`;
const Layout = styled(Box)`
flex-direction: column;
display: flex;
flex: 1;
max-width: 1248px;
`;
@@ -0,0 +1,19 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { ReviewAccessRequest } from './ReviewAccessRequest';
@@ -0,0 +1,222 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState, useEffect, useCallback } from 'react';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { AccessRequest } from 'shared/services/accessRequests';
import {
SubmitReview,
SuggestedAccessList,
RequestFlags,
} from 'shared/components/AccessRequests/ReviewRequests';
import { useAsync } from 'shared/hooks/useAsync';
import * as tsh from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
import { isUnimplementedError } from 'teleterm/services/tshd/errors';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
import { makeUiAccessRequest } from '../useAccessRequests';
export function useReviewAccessRequest({
requestId,
goBack,
}: {
requestId: string;
goBack(): void;
}) {
const ctx = useAppContext();
ctx.clustersService.useState();
const { localClusterUri: clusterUri, rootClusterUri } = useWorkspaceContext();
const loggedInUser = useWorkspaceLoggedInUser();
const assumed = ctx.clustersService.getAssumedRequests(rootClusterUri);
const retry = useCallback(
<T>(action: () => Promise<T>) => retryWithRelogin(ctx, clusterUri, action),
[clusterUri, ctx]
);
const [fetchRequestAttempt, runFetchRequest] = useAsync(
useCallback(
() =>
retry(async () => {
const request = await ctx.clustersService.getAccessRequest(
rootClusterUri,
requestId
);
return makeUiAccessRequest(request);
}),
[ctx.clustersService, requestId, retry, rootClusterUri]
)
);
const [deleteRequestAttempt, runDeleteRequest] = useAsync(() =>
retry(() =>
ctx.clustersService.deleteAccessRequest(rootClusterUri, requestId)
)
);
const [submitReviewAttempt, runSubmitReview] = useAsync(
(review: SubmitReview) =>
retry(async () => {
// This should not happen because the UI is hidden when fetching the request is in progress.
if (fetchRequestAttempt.status !== 'success') {
throw new Error('No access request to review.');
}
const updatedAccessRequest =
review.state === 'PROMOTED' && review.promotedToAccessList
? await ctx.clustersService.promoteAccessRequest({
rootClusterUri,
accessRequestId: requestId,
reason: review.reason,
accessListId: review.promotedToAccessList.id,
})
: await ctx.clustersService.reviewAccessRequest({
rootClusterUri,
state: review.state,
reason: review.reason,
roles: fetchRequestAttempt.data.roles,
accessRequestId: requestId,
assumeStartTime:
review.assumeStartTime &&
Timestamp.fromDate(review.assumeStartTime),
});
return makeUiAccessRequest(updatedAccessRequest);
})
);
const [fetchSuggestedAccessListsAttempt, runFetchSuggestedAccessLists] =
useAsync(
useCallback(async () => {
try {
const { response } = await ctx.tshd.getSuggestedAccessLists({
rootClusterUri,
accessRequestId: requestId,
});
return response.accessLists.map(makeUiAccessList);
} catch (e) {
if (isUnimplementedError(e)) {
// TODO(gzdunek): DELETE IN 16.0.0
throw new Error(
'To approve long-term access via Access List in Teleport Connect, update your cluster to 13.4.13 or 14.3.'
);
}
throw e;
}
}, [ctx.tshd, requestId, rootClusterUri])
);
function getFlags(request: AccessRequest): RequestFlags {
if (loggedInUser) {
return getRequestFlags(request, loggedInUser, assumed);
}
return undefined;
}
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => {
if (fetchRequestAttempt.status === '') {
runFetchRequest();
}
if (fetchSuggestedAccessListsAttempt.status === '') {
runFetchSuggestedAccessLists();
}
}, [
fetchRequestAttempt.status,
fetchSuggestedAccessListsAttempt.status,
runFetchRequest,
runFetchSuggestedAccessLists,
]);
async function deleteRequest(): Promise<void> {
const [, error] = await runDeleteRequest();
if (!error) {
goBack();
}
}
return {
user: loggedInUser,
getFlags,
fetchRequestAttempt,
submitReviewAttempt,
deleteDialogOpen,
setDeleteDialogOpen,
deleteRequestAttempt,
deleteRequest,
submitReview: runSubmitReview,
fetchSuggestedAccessListsAttempt,
};
}
function getRequestFlags(
request: AccessRequest,
user: tsh.LoggedInUser,
assumedMap: Record<string, tsh.AssumedRequest>
): RequestFlags {
const ownRequest = request.user === user.name;
const canAssume = ownRequest && request.state === 'APPROVED';
const isAssumed = !!assumedMap[request.id];
const canDelete = true;
const reviewed = request.reviews.find(r => r.author === user.name);
const isPromoted = request.state === 'PROMOTED';
const isPendingState = reviewed
? reviewed.state === 'PENDING'
: request.state === 'PENDING';
return {
canAssume,
isAssumed,
canDelete,
canReview: !ownRequest && isPendingState,
isPromoted,
ownRequest,
};
}
// Should be kept in sync with accessmanagement.makeAccessList().
function makeUiAccessList(al: tsh.AccessList): SuggestedAccessList {
const spec = al.spec;
const metadata = al.header.metadata;
return {
id: metadata.name,
title: spec.title,
description: spec.description,
grants: {
roles: spec.grants.roles.sort(),
traits: spec.grants.traits.reduce<Record<string, string[]>>(
(accumulator, trait) => {
accumulator[trait.key] = trait.values;
return accumulator;
},
{}
),
},
};
}
@@ -0,0 +1,19 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
export { DocumentAccessRequests } from './DocumentAccessRequests';
@@ -0,0 +1,162 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { AccessRequest } from 'shared/services/accessRequests';
import { AccessRequest as TshdAccessRequest } from 'teleterm/services/tshd/types';
import { makeUiAccessRequest } from './useAccessRequests';
test('makeUiAccessRequest', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-03-12T00:00:00.000Z'));
const request: TshdAccessRequest = {
id: '018e1246-0f5c-7829-8b05-8efac30fe18e',
state: 'PENDING',
resolveReason: 'resolve-reason',
requestReason: 'request-reason',
user: 'sevy',
roles: ['access'],
reviews: [
{
author: 'llama',
state: 'DENIED',
roles: ['admin'],
reason: 'not today',
promotedAccessListTitle: '',
created: { seconds: 1709703565, nanos: 148537000 },
},
],
suggestedReviewers: ['sugested-reviewer-1'],
thresholdNames: ['default'],
resourceIds: [],
resources: [
{
id: {
name: 'name',
kind: 'node',
clusterName: 'cluster',
subResourceName: 'subResourceName',
},
},
{
id: {
clusterName: 'cluster',
name: 'name',
kind: 'node',
subResourceName: 'subResourceName',
},
details: { hostname: 'hostname', friendlyName: 'friendlyName' },
},
],
promotedAccessListTitle: 'promoted-title',
created: {
seconds: 1709703565,
nanos: 148537000,
},
expires: {
seconds: 1709746587,
nanos: 999998000,
},
maxDuration: {
seconds: 1709746587,
nanos: 999998000,
},
requestTtl: {
seconds: 1710308365,
nanos: 148880000,
},
sessionTtl: {
seconds: 1709746587,
nanos: 999998000,
},
assumeStartTime: {
seconds: 1709853650,
nanos: 520000000,
},
};
const processedRequest: AccessRequest = {
created: new Date('2024-03-06T05:39:25.149Z'),
createdDuration: '6 days ago',
expires: new Date('2024-03-06T17:36:28.000Z'),
expiresDuration: '5 days',
id: '018e1246-0f5c-7829-8b05-8efac30fe18e',
maxDuration: new Date('2024-03-06T17:36:28.000Z'),
maxDurationText: '5 days',
promotedAccessListTitle: 'promoted-title',
requestReason: 'request-reason',
requestTTL: new Date('2024-03-13T05:39:25.149Z'),
requestTTLDuration: '1 day',
resolveReason: 'resolve-reason',
resources: [
{
id: {
clusterName: 'cluster',
kind: 'node',
name: 'name',
subResourceName: 'subResourceName',
},
},
{
details: {
friendlyName: 'friendlyName',
hostname: 'hostname',
},
id: {
clusterName: 'cluster',
kind: 'node',
name: 'name',
subResourceName: 'subResourceName',
},
},
],
reviewers: [
{
name: 'sugested-reviewer-1',
state: 'PENDING',
},
{
name: 'llama',
state: 'DENIED',
},
],
reviews: [
{
author: 'llama',
createdDuration: '6 days ago',
promotedAccessListTitle: '',
reason: 'not today',
roles: ['admin'],
state: 'DENIED',
assumeStartTime: null,
},
],
roles: ['access'],
sessionTTL: new Date('2024-03-06T17:36:28.000Z'),
sessionTTLDuration: '5 days',
state: 'PENDING',
thresholdNames: ['default'],
user: 'sevy',
assumeStartTime: new Date('2024-03-07T23:20:50.520Z'),
assumeStartTimeDuration: 'now',
};
expect(makeUiAccessRequest(request)).toStrictEqual(processedRequest);
});
@@ -0,0 +1,175 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useState, useEffect } from 'react';
import useAttempt from 'shared/hooks/useAttemptNext';
import {
makeAccessRequest,
AccessRequest,
} from 'shared/services/accessRequests';
import { RequestFlags } from 'shared/components/AccessRequests/ReviewRequests';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import * as types from 'teleterm/ui/services/workspacesService';
import {
AssumedRequest,
LoggedInUser,
AccessRequest as TshdAccessRequest,
} from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
export default function useAccessRequests(doc: types.DocumentAccessRequests) {
const ctx = useAppContext();
ctx.clustersService.useState();
const {
localClusterUri: clusterUri,
rootClusterUri,
documentsService,
} = useWorkspaceContext();
const assumed = ctx.clustersService.getAssumedRequests(rootClusterUri);
const loggedInUser = useWorkspaceLoggedInUser();
const [accessRequests, setAccessRequests] = useState<AccessRequest[]>();
const { attempt, setAttempt } = useAttempt('');
function goBack() {
documentsService.update(doc.uri, {
title: `Access Requests`,
state: 'browsing',
requestId: '',
});
}
function onViewRequest(requestId: string) {
documentsService.update(doc.uri, {
title: `Request: ${requestId}`,
state: 'reviewing',
requestId,
});
}
const getRequests = async () => {
try {
const response = await retryWithRelogin(ctx, clusterUri, () =>
ctx.clustersService.getAccessRequests(rootClusterUri)
);
setAttempt({ status: 'success' });
// transform tshd access request to the webui access request and add flags
const requests = response.map(r => makeUiAccessRequest(r));
setAccessRequests(requests);
} catch (err) {
setAttempt({
status: 'failed',
statusText: err.message,
});
}
};
useEffect(() => {
// only fetch when visitng RequestList
if (doc.state === 'browsing') {
getRequests();
}
}, [doc.state, clusterUri]);
useEffect(() => {
// if assumed object changes, we update which roles have been assumed in the table
// this is mostly for using "Switchback" since that state is held outside this component
setAccessRequests(prevState =>
prevState?.map(r => ({
...r,
isAssumed: assumed[r.id],
}))
);
}, [assumed]);
return {
ctx,
attempt,
accessRequests,
onViewRequest,
doc,
getRequests,
getFlags: (accessRequest: AccessRequest) =>
makeFlags(accessRequest, assumed, loggedInUser),
goBack,
};
}
export function makeUiAccessRequest(request: TshdAccessRequest) {
return makeAccessRequest({
...request,
created: Timestamp.toDate(request.created),
expires: Timestamp.toDate(request.expires),
maxDuration: request.maxDuration && Timestamp.toDate(request.maxDuration),
requestTTL: request.requestTtl && Timestamp.toDate(request.requestTtl),
sessionTTL: request.sessionTtl && Timestamp.toDate(request.sessionTtl),
assumeStartTime:
request.assumeStartTime && Timestamp.toDate(request.assumeStartTime),
roles: request.roles,
reviews: request.reviews.map(review => ({
...review,
created: Timestamp.toDate(review.created),
assumeStartTime:
review.assumeStartTime && Timestamp.toDate(review.assumeStartTime),
})),
suggestedReviewers: request.suggestedReviewers,
thresholdNames: request.thresholdNames,
resources: request.resources,
});
}
// transform tsdh Access Request type into the web's Access Request
// to promote code reuse
// TODO(gzdunek): Replace with a function from `DocumentAccessRequests/useReviewAccessRequest`.
export function makeFlags(
request: AccessRequest,
assumed: Record<string, AssumedRequest>,
loggedInUser: LoggedInUser
): RequestFlags {
const ownRequest = request.user === loggedInUser?.name;
const canAssume = ownRequest && request.state === 'APPROVED';
const isAssumed = !!assumed[request.id];
const isPromoted =
request.state === 'PROMOTED' && !!request.promotedAccessListTitle;
const reviewed = request.reviews.find(r => r.author === loggedInUser?.name);
const isPendingState = reviewed
? reviewed.state === 'PENDING'
: request.state === 'PENDING';
return {
...request,
canAssume,
isAssumed,
canReview: !ownRequest && isPendingState,
canDelete: true,
ownRequest,
isPromoted,
};
}
@@ -0,0 +1,78 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { useAsync } from 'shared/hooks/useAsync';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { useResourcesContext } from 'teleterm/ui/DocumentCluster/resourcesContext';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
export function useAssumeAccess() {
const ctx = useAppContext();
const {
localClusterUri: clusterUri,
rootClusterUri,
documentsService,
} = useWorkspaceContext();
const { requestResourcesRefresh } = useResourcesContext();
const [assumeRoleAttempt, runAssumeRole] = useAsync((requestId: string) =>
retryWithRelogin(ctx, clusterUri, async () => {
await ctx.clustersService.assumeRole(rootClusterUri, [requestId], []);
// refresh the current resource tabs
requestResourcesRefresh();
})
);
async function assumeAccessList(): Promise<void> {
const { hasLoggedIn } = await new Promise<{
hasLoggedIn: boolean;
}>(resolve => {
ctx.modalsService.openRegularDialog({
kind: 'cluster-connect',
clusterUri: rootClusterUri,
onCancel: () => resolve({ hasLoggedIn: false }),
onSuccess: () => resolve({ hasLoggedIn: true }),
prefill: undefined,
reason: undefined,
});
});
if (!hasLoggedIn) {
return;
}
// refresh the current resource tabs
requestResourcesRefresh();
// open new cluster tab
const clusterDocument = documentsService.createClusterDocument({
clusterUri,
queryParams: undefined,
});
documentsService.add(clusterDocument);
documentsService.open(clusterDocument.uri);
}
return {
assumeAccessList,
assumeRole: runAssumeRole,
assumeRoleAttempt,
};
}
@@ -22,10 +22,7 @@ import { createPortal } from 'react-dom';
import styled from 'styled-components';
import { Text } from 'design';
/* eslint-disable @typescript-eslint/ban-ts-comment*/
// @ts-ignore
import { DocumentAccessRequests } from 'e-teleterm/ui/DocumentAccessRequests/DocumentAccessRequests';
import { DocumentAccessRequests } from 'teleterm/ui/DocumentAccessRequests';
import { DocumentGatewayCliClient } from 'teleterm/ui/DocumentGatewayCliClient';
import { useAppContext } from 'teleterm/ui/appContextProvider';
@@ -18,10 +18,8 @@
import React, { useRef } from 'react';
import { Flex } from 'design';
/* eslint-disable @typescript-eslint/ban-ts-comment*/
// @ts-ignore
import { AccessRequestCheckout } from 'e-teleterm/ui/AccessRequestCheckout';
import { AccessRequestCheckout } from 'teleterm/ui/AccessRequestCheckout';
import { TabHostContainer } from 'teleterm/ui/TabHost';
import { TopBar } from 'teleterm/ui/TopBar';
import { StatusBar } from 'teleterm/ui/StatusBar';
@@ -0,0 +1,45 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import { ButtonPrimary, Text } from 'design';
import { EnvelopeOpen } from 'design/Icon';
import { useAccessRequestsButton } from 'teleterm/ui/StatusBar/useAccessRequestCheckoutButton';
export function AccessRequestCheckoutButton() {
const { toggleAccessRequestBar, getPendingResourceCount, isCollapsed } =
useAccessRequestsButton();
const count = getPendingResourceCount();
if (count > 0 && isCollapsed()) {
return (
<ButtonPrimary
onClick={toggleAccessRequestBar}
px={2}
size="small"
title="Toggle Access Request Checkout"
>
<EnvelopeOpen mr={2} size="small" color="buttons.primary.text" />
<Text fontSize="12px">{count}</Text>
</ButtonPrimary>
);
}
return null;
}
@@ -16,14 +16,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment*/
import React from 'react';
import { Flex, Text } from 'design';
// @ts-ignore
import { AccessRequestCheckoutButton } from 'e-teleterm/ui/StatusBar/AccessRequestCheckoutButton';
import { useActiveDocumentClusterBreadcrumbs } from './useActiveDocumentClusterBreadcrumbs';
import { ShareFeedback } from './ShareFeedback';
import { AccessRequestCheckoutButton } from './AccessRequestCheckoutButton';
export function StatusBar() {
const clusterBreadcrumbs = useActiveDocumentClusterBreadcrumbs();
@@ -16,10 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment*/
// @ts-ignore
import { AccessRequest } from 'e-teleport/services/workflow';
import { ImmutableStore } from 'teleterm/ui/services/immutableStore';
import {
@@ -47,23 +43,14 @@ function getMockPendingAccessRequest(): PendingAccessRequest {
};
}
function getMockAssumed(assumed = {}): Record<string, AccessRequest> {
return assumed;
}
function createService(
pending: PendingAccessRequest,
assumed: Record<string, AccessRequest>
): AccessRequestsService {
function createService(pending: PendingAccessRequest): AccessRequestsService {
const store = new ImmutableStore<{
isBarCollapsed: boolean;
pending: PendingAccessRequest;
assumed: Record<string, AccessRequest>;
}>();
store.state = {
isBarCollapsed: false,
pending,
assumed,
};
return new AccessRequestsService(
() => store.state,
@@ -72,22 +59,19 @@ function createService(
}
test('getCollapsed() returns the bar collapse state', () => {
let service = createService(getMockPendingAccessRequest(), getMockAssumed());
let service = createService(getMockPendingAccessRequest());
expect(service.getCollapsed()).toBe(false);
});
test('toggleBar() changes the collapse state', () => {
let service = createService(getMockPendingAccessRequest(), getMockAssumed());
let service = createService(getMockPendingAccessRequest());
expect(service.getCollapsed()).toBe(false);
service.toggleBar();
expect(service.getCollapsed()).toBe(true);
});
test('clearPendingAccessRequest() clears pending access reuqest', () => {
let service = createService(
getMockPendingAccessRequest(),
getMockAssumed({})
);
let service = createService(getMockPendingAccessRequest());
service.clearPendingAccessRequest();
expect(service.getPendingAccessRequest()).toStrictEqual(
getEmptyPendingAccessRequest()
@@ -95,40 +79,28 @@ test('clearPendingAccessRequest() clears pending access reuqest', () => {
});
test('getAddedResourceCount() returns added resource count for pending request', () => {
let service = createService(
getMockPendingAccessRequest(),
getMockAssumed({})
);
let service = createService(getMockPendingAccessRequest());
expect(service.getAddedResourceCount()).toBe(3);
service.clearPendingAccessRequest();
expect(service.getAddedResourceCount()).toBe(0);
});
test('addOrRemoveResource() adds resource to pending request', () => {
let service = createService(
getMockPendingAccessRequest(),
getMockAssumed({})
);
let service = createService(getMockPendingAccessRequest());
service.addOrRemoveResource('node', '456', 'node2');
const pendingAccessRequest = service.getPendingAccessRequest();
expect(pendingAccessRequest['node']).toHaveProperty('456');
});
test('addOrRemoveResource() removes resource if it already exists on pending request', () => {
let service = createService(
getMockPendingAccessRequest(),
getMockAssumed({})
);
let service = createService(getMockPendingAccessRequest());
service.addOrRemoveResource('node', '123', 'node1');
const pendingAccessRequest = service.getPendingAccessRequest();
expect(pendingAccessRequest['node']).not.toHaveProperty('123');
});
test('addOrRemoveResource() uses resourceId when resourceName is empty', () => {
let service = createService(
getMockPendingAccessRequest(),
getMockAssumed({})
);
let service = createService(getMockPendingAccessRequest());
const resourceId = '567';
const resourceName = '';
@@ -16,9 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment*/
// @ts-ignore
import { ResourceKind } from 'e-teleterm/ui/DocumentAccessRequests/NewRequest/useNewRequest';
import { ResourceKind } from 'shared/components/AccessRequests/NewRequest';
import type { PendingAccessRequest } from '../workspacesService';
@@ -20,9 +20,7 @@ import { z } from 'zod';
import { useStore } from 'shared/libs/stores';
import { arrayObjectIsEqual } from 'shared/utils/highbar';
/* eslint-disable @typescript-eslint/ban-ts-comment*/
// @ts-ignore
import { ResourceKind } from 'e-teleport/Workflow/NewRequest/useNewRequest';
import { ResourceKind } from 'shared/components/AccessRequests/NewRequest';
import {
DefaultTab,