2062 view edit an api key (#2231)

* Add query to get api keys

* Add a link to apiKey detail page

* Reset generatedApiKey when leaving page

* Simplify stuff

* Regenerate key when clicking on button

* Simplify

* Fix test

* Refetch apiKeys when delete or create one

* Add test for utils

* Create utils function

* Enable null expiration dates

* Update formatExpiration

* Fix display

* Fix noteCard

* Fix errors

* Fix reset

* Fix display

* Fix renaming

* Fix tests

* Fix ci

* Fix mocked data

* Fix test

* Update coverage requiremeents

* Rename folder

* Code review returns

* Symplify sht code
This commit is contained in:
martmull
2023-10-26 11:32:44 +02:00
committed by GitHub
parent 2b1945a3e1
commit fc4075b372
34 changed files with 434 additions and 183 deletions

View File

@ -9,7 +9,7 @@ import {
GenericFieldContextType,
} from '@/ui/data/field/contexts/FieldContext';
import { IconComment } from '@/ui/display/icon';
import { Activity, ActivityTarget } from '~/generated/graphql';
import { Activity, ActivityTarget, Comment } from '~/generated/graphql';
const StyledCard = styled.div`
align-items: flex-start;
@ -76,9 +76,10 @@ export const NoteCard = ({
}: {
note: Pick<
Activity,
'id' | 'title' | 'body' | 'type' | 'completedAt' | 'dueAt' | 'comments'
'id' | 'title' | 'body' | 'type' | 'completedAt' | 'dueAt'
> & {
activityTargets?: Array<Pick<ActivityTarget, 'id'>> | null;
comments?: Array<Pick<Comment, 'id'>> | null;
};
}) => {
const theme = useTheme();

View File

@ -5,7 +5,6 @@ import { IconCopy } from '@/ui/display/icon';
import { useSnackBar } from '@/ui/feedback/snack-bar/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { beautifyDateDiff } from '~/utils/date-utils';
const StyledContainer = styled.div`
display: flex;
@ -17,22 +16,16 @@ const StyledLinkContainer = styled.div`
margin-right: ${({ theme }) => theme.spacing(2)};
`;
type ApiKeyInputProps = { expiresAt?: string | null; apiKey: string };
type ApiKeyInputProps = { apiKey: string };
export const ApiKeyInput = ({ expiresAt, apiKey }: ApiKeyInputProps) => {
export const ApiKeyInput = ({ apiKey }: ApiKeyInputProps) => {
const theme = useTheme();
const computeInfo = () => {
if (!expiresAt) {
return '';
}
return `This key will expire in ${beautifyDateDiff(expiresAt)}`;
};
const { enqueueSnackBar } = useSnackBar();
return (
<StyledContainer>
<StyledLinkContainer>
<TextInput info={computeInfo()} value={apiKey} fullWidth />
<TextInput value={apiKey} fullWidth />
</StyledLinkContainer>
<Button
Icon={IconCopy}

View File

@ -1,12 +1,11 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem';
import { IconChevronRight } from '@/ui/display/icon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ApisFiedlItem } from '../types/ApisFieldItem';
export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 180px 148px 148px 36px;
`;
@ -27,13 +26,15 @@ const StyledIconChevronRight = styled(IconChevronRight)`
export const SettingsApiKeysFieldItemTableRow = ({
fieldItem,
onClick,
}: {
fieldItem: ApisFiedlItem;
fieldItem: ApiFieldItem;
onClick: () => void;
}) => {
const theme = useTheme();
return (
<StyledApisFieldTableRow onClick={() => {}}>
<StyledApisFieldTableRow onClick={() => onClick()}>
<StyledNameTableCell>{fieldItem.name}</StyledNameTableCell>
<TableCell color={theme.font.color.tertiary}>Internal</TableCell>{' '}
<TableCell

View File

@ -8,7 +8,6 @@ const meta: Meta<typeof ApiKeyInput> = {
component: ApiKeyInput,
decorators: [ComponentDecorator],
args: {
expiresAt: '2123-11-06T23:59:59.825Z',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MTQyODgyLCJleHAiOjE2OTk0MDE1OTksImp0aSI6ImMyMmFiNjQxLTVhOGYtNGQwMC1iMDkzLTk3MzUwYTM2YzZkOSJ9.JIe2TX5IXrdNl3n-kRFp3jyfNUE7unzXZLAzm2Gxl98',
},

View File

@ -1,5 +1,5 @@
export const ExpirationDates: {
value: number;
value: number | null;
label: string;
}[] = [
{ label: '15 days', value: 15 },
@ -7,5 +7,5 @@ export const ExpirationDates: {
{ label: '90 days', value: 90 },
{ label: '1 year', value: 365 },
{ label: '2 years', value: 2 * 365 },
{ label: 'Never', value: 10 * 365 },
{ label: 'Never', value: null },
];

View File

@ -1,36 +0,0 @@
import { v4 } from 'uuid';
import { ApisFiedlItem } from '../types/ApisFieldItem';
export const activeApiKeyItems: ApisFiedlItem[] = [
{
id: v4(),
name: 'Zapier key',
type: 'internal',
expiration: 'In 80 days',
},
{
id: v4(),
name: 'Notion',
type: 'internal',
expiration: 'Expired',
},
{
id: v4(),
name: 'Trello',
type: 'internal',
expiration: 'In 1 year',
},
{
id: v4(),
name: 'Cargo',
type: 'published',
expiration: 'Never',
},
{
id: v4(),
name: 'Backoffice',
type: 'published',
expiration: 'In 32 days',
},
];

View File

@ -6,6 +6,7 @@ export const GET_API_KEY = gql`
id
name
expiresAt
createdAt
}
}
`;

View File

@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const GET_API_KEYS = gql`
query GetApiKeys {
findManyApiKey {
id
name
expiresAt
createdAt
}
}
`;

View File

@ -0,0 +1,12 @@
import { useRecoilCallback } from 'recoil';
import { generatedApiKeyFamilyState } from '@/settings/developers/states/generatedApiKeyFamilyState';
export const useGeneratedApiKeys = () => {
return useRecoilCallback(
({ set }) =>
(apiKeyId: string, apiKey: string | null) => {
set(generatedApiKeyFamilyState(apiKeyId), apiKey);
},
);
};

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const generatedApiKeyFamilyState = atomFamily<
string | null | undefined,
string
>({
key: 'generatedApiKeyFamilyState',
default: null,
});

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const generatedApiKeyState = atom<string | null | undefined>({
key: 'generatedApiKeyState',
default: null,
});

View File

@ -1,4 +1,4 @@
export type ApisFiedlItem = {
export type ApiFieldItem = {
id: string;
name: string;
type: 'internal' | 'published';

View File

@ -0,0 +1,23 @@
import { computeNewExpirationDate } from '@/settings/developers/utils/compute-new-expiration-date';
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
describe('computeNewExpirationDate', () => {
it('should compute properly', () => {
const expiresAt = '2023-01-10T00:00:00.000Z';
const createdAt = '2023-01-01T00:00:00.000Z';
const result = computeNewExpirationDate(expiresAt, createdAt);
expect(result).toEqual('2024-01-10T00:00:00.000Z');
});
it('should compute properly with same values', () => {
const expiresAt = '2023-01-01T10:00:00.000Z';
const createdAt = '2023-01-01T10:00:00.000Z';
const result = computeNewExpirationDate(expiresAt, createdAt);
expect(result).toEqual('2024-01-01T00:00:00.000Z');
});
it('should compute properly with no expiration', () => {
const createdAt = '2023-01-01T10:00:00.000Z';
const result = computeNewExpirationDate(undefined, createdAt);
expect(result).toEqual(null);
});
});

View File

@ -0,0 +1,34 @@
import { formatExpiration } from '@/settings/developers/utils/format-expiration';
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
describe('formatExpiration', () => {
it('should format properly when expiresAt is null', () => {
const expiresAt = null;
const result = formatExpiration(expiresAt);
expect(result).toEqual('Never');
const resultWithExpiresMention = formatExpiration(expiresAt, true);
expect(resultWithExpiresMention).toEqual('Never expires');
});
it('should format properly when expiresAt is not null', () => {
const expiresAt = '2024-01-10T00:00:00.000Z';
const result = formatExpiration(expiresAt);
expect(result).toEqual('In 9 days');
const resultWithExpiresMention = formatExpiration(expiresAt, true);
expect(resultWithExpiresMention).toEqual('Expires in 9 days');
});
it('should format properly when expiresAt is large', () => {
const expiresAt = '2034-01-10T00:00:00.000Z';
const result = formatExpiration(expiresAt);
expect(result).toEqual('In 10 years');
const resultWithExpiresMention = formatExpiration(expiresAt, true);
expect(resultWithExpiresMention).toEqual('Expires in 10 years');
});
it('should format properly when expiresAt is large and long version', () => {
const expiresAt = '2034-01-10T00:00:00.000Z';
const result = formatExpiration(expiresAt, undefined, false);
expect(result).toEqual('In 10 years and 9 days');
const resultWithExpiresMention = formatExpiration(expiresAt, true, false);
expect(resultWithExpiresMention).toEqual('Expires in 10 years and 9 days');
});
});

View File

@ -0,0 +1,14 @@
import { DateTime } from 'luxon';
export const computeNewExpirationDate = (
expiresAt: string | null | undefined,
createdAt: string,
): string | null => {
if (!expiresAt) {
return null;
}
const days = DateTime.fromISO(expiresAt).diff(DateTime.fromISO(createdAt), [
'days',
]).days;
return DateTime.utc().plus({ days }).toISO();
};

View File

@ -0,0 +1,31 @@
import { ApiFieldItem } from '@/settings/developers/types/ApiFieldItem';
import { GetApiKeysQuery } from '~/generated/graphql';
import { beautifyDateDiff } from '~/utils/date-utils';
export const formatExpiration = (
expiresAt: string | null,
withExpiresMention: boolean = false,
short: boolean = true,
) => {
if (expiresAt) {
const dateDiff = beautifyDateDiff(expiresAt, undefined, short);
if (dateDiff.includes('-')) {
return 'Expired';
}
return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`;
}
return withExpiresMention ? 'Never expires' : 'Never';
};
export const formatExpirations = (
apiKeysQuery: GetApiKeysQuery,
): ApiFieldItem[] => {
return apiKeysQuery.findManyApiKey.map(({ id, name, expiresAt }) => {
return {
id,
name,
expiration: formatExpiration(expiresAt || null),
type: 'internal',
};
});
};

View File

@ -76,6 +76,7 @@ export {
IconPlug,
IconPlus,
IconProgressCheck,
IconRepeat,
IconRobot,
IconSearch,
IconSettings,

View File

@ -11,7 +11,7 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string | number> = {
export type SelectProps<Value extends string | number | null> = {
dropdownScopeId: string;
onChange: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
@ -38,7 +38,7 @@ const StyledLabel = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
export const Select = <Value extends string | number>({
export const Select = <Value extends string | number | null>({
dropdownScopeId,
onChange,
options,

View File

@ -25,7 +25,6 @@ export type TextInputComponentProps = Omit<
> & {
className?: string;
label?: string;
info?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
@ -46,13 +45,6 @@ const StyledLabel = styled.span`
text-transform: uppercase;
`;
const StyledInfo = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
@ -120,7 +112,6 @@ const TextInputComponent = (
{
className,
label,
info,
value,
onChange,
onFocus,
@ -212,7 +203,6 @@ const TextInputComponent = (
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{info && <StyledInfo>{info}</StyledInfo>}
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);

View File

@ -6,11 +6,11 @@ import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Select, SelectProps } from '../Select';
type RenderProps = SelectProps<string | number>;
type RenderProps = SelectProps<string | number | null>;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (value: string | number) => {
const handleChange = (value: string | number | null) => {
args.onChange?.(value);
setValue(value);
};

View File

@ -38,7 +38,3 @@ export const Filled: Story = {
export const Disabled: Story = {
args: { disabled: true, value: 'Tim' },
};
export const WithInfo: Story = {
args: { info: 'Some info displayed below the input', value: 'Tim' },
};