2060 create a new api key (#2206)

* Add folder for api settings

* Init create api key page

* Update create api key page

* Implement api call to create apiKey

* Add create api key mutation

* Get id when creating apiKey

* Display created Api Key

* Add delete api key button

* Remove button from InputText

* Update stuff

* Add test for ApiDetail

* Fix type

* Use recoil instead of router state

* Remane route paths

* Remove online return

* Move and test date util

* Remove useless Component

* Rename ApiKeys paths

* Rename ApiKeys files

* Add input text info testing

* Rename hooks to webhooks

* Remove console error

* Add tests to reach minimum coverage
This commit is contained in:
martmull
2023-10-24 16:14:54 +02:00
committed by GitHub
parent b6e8fabbb1
commit d61511262e
55 changed files with 919 additions and 133 deletions

View File

@ -24,7 +24,7 @@ export const CompanyBoard = ({
onEditColumnTitle,
}: CompanyBoardProps) => {
// TODO: we can store objectId and fieldDefinitions in the ViewBarContext
// And then use the useBoardViews hook wherever we need it in the board
// And then use the useBoardViews web-hook wherever we need it in the board
const { createView, deleteView, submitCurrentView, updateView } =
useBoardViews({
objectId: 'company',

View File

@ -41,7 +41,7 @@ export const SettingsNavbar = () => {
end: false,
});
const isDevelopersSettingsActive = !!useMatch({
path: useResolvedPath('/settings/api').pathname,
path: useResolvedPath('/settings/developers/api-keys').pathname,
end: true,
});
@ -104,7 +104,7 @@ export const SettingsNavbar = () => {
{isDevelopersSettingsEnabled && (
<NavItem
label="Developers"
to="/settings/apis"
to="/settings/developers/api-keys"
Icon={IconRobot}
active={isDevelopersSettingsActive}
/>

View File

@ -0,0 +1,51 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
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;
flex-direction: row;
`;
const StyledLinkContainer = styled.div`
flex: 1;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
type ApiKeyInputProps = { expiresAt?: string | null; apiKey: string };
export const ApiKeyInput = ({ expiresAt, 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 />
</StyledLinkContainer>
<Button
Icon={IconCopy}
title="Copy"
onClick={() => {
enqueueSnackBar('Api Key copied to clipboard', {
variant: 'success',
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(apiKey);
}}
/>
</StyledContainer>
);
};

View File

@ -25,7 +25,7 @@ const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsApisFieldItemTableRow = ({
export const SettingsApiKeysFieldItemTableRow = ({
fieldItem,
}: {
fieldItem: ApisFiedlItem;

View File

@ -0,0 +1,19 @@
import { Meta, StoryObj } from '@storybook/react';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof ApiKeyInput> = {
title: 'Pages/Settings/Developers/ApiKeys/ApiKeyInput',
component: ApiKeyInput,
decorators: [ComponentDecorator],
args: {
expiresAt: '2123-11-06T23:59:59.825Z',
apiKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0d2VudHktN2VkOWQyMTItMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNjk4MTQyODgyLCJleHAiOjE2OTk0MDE1OTksImp0aSI6ImMyMmFiNjQxLTVhOGYtNGQwMC1iMDkzLTk3MzUwYTM2YzZkOSJ9.JIe2TX5IXrdNl3n-kRFp3jyfNUE7unzXZLAzm2Gxl98',
},
};
export default meta;
type Story = StoryObj<typeof ApiKeyInput>;
export const Default: Story = {};

View File

@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator],
args: {
fieldItem: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
name: 'Zapier Api Key',
type: 'internal',
expiration: 'In 3 days',
},
},
};
export default meta;
type Story = StoryObj<typeof SettingsApiKeysFieldItemTableRow>;
export const Default: Story = {};

View File

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

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_ONE_API_KEY = gql`
mutation DeleteOneApiKey($apiKeyId: String!) {
revokeOneApiKey(where: { id: $apiKeyId }) {
id
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const INSERT_ONE_API_KEY = gql`
mutation InsertOneApiKey($data: ApiKeyCreateInput!) {
createOneApiKey(data: $data) {
id
token
expiresAt
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_API_KEY = gql`
query GetApiKey($apiKeyId: String!) {
findManyApiKey(where: { id: { equals: $apiKeyId } }) {
id
name
expiresAt
}
}
`;

View File

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

View File

@ -36,7 +36,7 @@ export const NameFields = ({
const [updateUser] = useUpdateUserMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = debounce(async () => {
if (onFirstNameUpdate) {
onFirstNameUpdate(firstName);

View File

@ -34,7 +34,7 @@ export const NameField = ({
const [updateWorkspace] = useUpdateWorkspaceMutation();
// TODO: Enhance this with react-hook-form (https://www.react-hook-form.com)
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdate = useCallback(
debounce(async (name: string) => {

View File

@ -20,6 +20,7 @@ export enum AppPath {
ObjectTablePage = '/objects/:objectNamePlural',
SettingsCatchAll = `/settings/*`,
DevelopersCatchAll = `/developers/*`,
// Impersonate
Impersonate = '/impersonate/:userId',

View File

@ -9,5 +9,7 @@ export enum SettingsPath {
NewObject = 'objects/new',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
Apis = 'apis',
Developers = 'api-keys',
DevelopersNewApiKey = 'api-keys/new',
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
}

View File

@ -5,7 +5,7 @@ import { FieldMetadata } from '../types/FieldMetadata';
export type GenericFieldContextType = {
fieldDefinition: FieldDefinition<FieldMetadata>;
// TODO: add better typing for mutation hook
// TODO: add better typing for mutation web-hook
useUpdateEntityMutation: () => [(params: any) => void, any];
entityId: string;
recoilScopeId: string;

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> = {
export type SelectProps<Value extends string | number> = {
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>({
export const Select = <Value extends string | number>({
dropdownScopeId,
onChange,
options,

View File

@ -25,6 +25,7 @@ export type TextInputComponentProps = Omit<
> & {
className?: string;
label?: string;
info?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
@ -45,10 +46,16 @@ 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;
width: 100%;
`;
@ -113,6 +120,7 @@ const TextInputComponent = (
{
className,
label,
info,
value,
onChange,
onFocus,
@ -204,6 +212,7 @@ 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>;
type RenderProps = SelectProps<string | number>;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (value: string) => {
const handleChange = (value: string | number) => {
args.onChange?.(value);
setValue(value);
};

View File

@ -38,3 +38,7 @@ 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' },
};

View File

@ -4,7 +4,7 @@ import { companyProgressesFamilyState } from '@/companies/states/companyProgress
import { boardCardIdsByColumnIdFamilyState } from '../boardCardIdsByColumnIdFamilyState';
// TODO: this state should be computed during the synchronization hook and put in a generic
// TODO: this state should be computed during the synchronization web-hook and put in a generic
// boardColumnTotalsFamilyState indexed by columnId.
export const boardColumnTotalsFamilySelector = selectorFamily({
key: 'boardColumnTotalsFamilySelector',

View File

@ -22,7 +22,7 @@ const TestComponentDomMode = () => {
);
};
test('useListenClickOutside hook works in dom mode', async () => {
test('useListenClickOutside web-hook works in dom mode', async () => {
const { getByText } = render(<TestComponentDomMode />);
const inside = getByText('Inside');
const inside2 = getByText('Inside 2');