From ecdf2a13a4662efa66b0ac01e85c6947652970cd Mon Sep 17 00:00:00 2001 From: Tuval Simha Date: Fri, 19 Jul 2024 17:05:41 +0300 Subject: [PATCH] Drop modals from V2: Create Access Token Modal (#5244) --- .../target/settings/registry-access-token.tsx | 319 ++++++++++++++++++ .../v2/modals/create-access-token.tsx | 251 -------------- .../web/app/src/components/v2/modals/index.ts | 1 - packages/web/app/src/lib/urql-cache.ts | 2 +- .../web/app/src/pages/target-settings.tsx | 3 +- .../stories/registry-access-token.stories.tsx | 119 +++++++ 6 files changed, 441 insertions(+), 254 deletions(-) create mode 100644 packages/web/app/src/components/target/settings/registry-access-token.tsx delete mode 100644 packages/web/app/src/components/v2/modals/create-access-token.tsx create mode 100644 packages/web/app/src/stories/registry-access-token.stories.tsx diff --git a/packages/web/app/src/components/target/settings/registry-access-token.tsx b/packages/web/app/src/components/target/settings/registry-access-token.tsx new file mode 100644 index 000000000..3c2a06f36 --- /dev/null +++ b/packages/web/app/src/components/target/settings/registry-access-token.tsx @@ -0,0 +1,319 @@ +import { useState } from 'react'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import { AnyVariables, useMutation, UseMutationState, useQuery } from 'urql'; +import { z } from 'zod'; +import { Tag } from '@/components//v2/tag'; +import { PermissionScopeItem, usePermissionsManager } from '@/components/organization/Permissions'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useToast } from '@/components/ui/use-toast'; +import { Accordion } from '@/components/v2/accordion'; +import { CopyValue } from '@/components/v2/copy-value'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { TargetAccessScope } from '@/gql/graphql'; +import { RegistryAccessScope } from '@/lib/access/common'; +import { zodResolver } from '@hookform/resolvers/zod'; + +export const CreateAccessToken_CreateTokenMutation = graphql(` + mutation CreateAccessToken_CreateToken($input: CreateTokenInput!) { + createToken(input: $input) { + ok { + selector { + organization + project + target + } + createdToken { + id + name + alias + date + lastUsedAt + } + secret + } + error { + message + } + } + } +`); + +const CreateAccessTokenModalQuery = graphql(` + query CreateAccessTokenModalQuery($organizationId: ID!) { + organization(selector: { organization: $organizationId }) { + organization { + ...CreateAccessTokenModalContent_OrganizationFragment + } + } + } +`); + +export function CreateAccessTokenModal(props: { + isOpen: boolean; + toggleModalOpen: () => void; + organizationId: string; + projectId: string; + targetId: string; +}) { + const { isOpen, toggleModalOpen } = props; + const [organizationQuery] = useQuery({ + query: CreateAccessTokenModalQuery, + variables: { + organizationId: props.organizationId, + }, + }); + + const organization = organizationQuery.data?.organization?.organization; + + return ( + + {organization ? ( + + ) : ( + + + Organization not found + + The organization you are trying to access does not exist. + + + + + + + )} + + ); +} + +const CreateAccessTokenModalContent_OrganizationFragment = graphql(` + fragment CreateAccessTokenModalContent_OrganizationFragment on Organization { + id + ...UsePermissionManager_OrganizationFragment + me { + ...UsePermissionManager_MemberFragment + } + } +`); + +function getFinalTargetAccessScopes( + selectedScope: 'no-access' | TargetAccessScope, +): Array { + if (selectedScope === 'no-access') { + return []; + } + if (selectedScope === TargetAccessScope.RegistryWrite) { + return [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite]; + } + return [TargetAccessScope.RegistryRead]; +} + +const createRegistryTokenFormSchema = z.object({ + tokenDescription: z + .string({ + required_error: 'Token description is required', + }) + .min(2, { + message: 'Token description must be at least 2 characters long', + }) + .max(50, { + message: 'Token description must be at most 50 characters long', + }) + .regex( + /^([a-z]|[0-9]|\s|\.|,|_|-|\/|&)+$/i, + 'Token description restricted to alphanumerical characters, spaces and . , _ - / &', + ), +}); + +export function ModalContent(props: { + organization: FragmentType; + organizationId: string; + projectId: string; + targetId: string; + toggleModalOpen: () => void; +}) { + const { toast } = useToast(); + const organization = useFragment( + CreateAccessTokenModalContent_OrganizationFragment, + props.organization, + ); + const [selectedScope, setSelectedScope] = useState<'no-access' | TargetAccessScope>('no-access'); + + const manager = usePermissionsManager({ + onSuccess() {}, + organization, + member: organization.me, + passMemberScopes: false, + }); + + const form = useForm>({ + mode: 'onChange', + resolver: zodResolver(createRegistryTokenFormSchema), + defaultValues: { + tokenDescription: '', + }, + }); + + const [mutation, mutate] = useMutation(CreateAccessToken_CreateTokenMutation); + + async function onSubmit(values: z.infer) { + const { error } = await mutate({ + input: { + organization: props.organizationId, + project: props.projectId, + target: props.targetId, + name: values.tokenDescription, + organizationScopes: [], + projectScopes: [], + targetScopes: getFinalTargetAccessScopes(selectedScope), + }, + }); + if (error) { + toast({ + variant: 'destructive', + title: 'Failed to create token', + description: error.message, + }); + } else { + toast({ + title: 'Token created', + description: 'The token has been successfully created.', + }); + } + } + + const noPermissionsSelected = selectedScope === 'no-access'; + if (mutation.data?.createToken.ok) { + return ; + } + + return ( + + ); +} + +export function CreatedTokenContent(props: { + mutation: UseMutationState; + toggleModalOpen: () => void; +}) { + return ( + + + Token successfully created! + + + + This is your unique API key and it is non-recoverable. If you lose this key, you will + need to create a new one. + + + + + + + + ); +} + +export function GenerateTokenContent(props: { + form: UseFormReturn>; + onSubmit: (values: z.infer) => void; + manager: ReturnType; + setSelectedScope: (scope: 'no-access' | TargetAccessScope) => void; + selectedScope: 'no-access' | TargetAccessScope; + toggleModalOpen: () => void; + noPermissionsSelected: boolean; +}) { + return ( + +
+ + + Create an access token + + To access GraphQL Hive, your application or tool needs an active API key. + + + ( + + + + + + + )} + /> +
+ + + Registry & Usage + + { + if (value === 'no-access') { + props.setSelectedScope('no-access'); + return; + } + props.setSelectedScope(value); + }} + possibleScope={Object.values(RegistryAccessScope.mapping)} + initialScope={props.selectedScope} + selectedScope={props.selectedScope} // Ensure selectedScope is passed correctly + /> + + + +
+ + + + + + +
+ ); +} diff --git a/packages/web/app/src/components/v2/modals/create-access-token.tsx b/packages/web/app/src/components/v2/modals/create-access-token.tsx deleted file mode 100644 index 39974ce81..000000000 --- a/packages/web/app/src/components/v2/modals/create-access-token.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { ReactElement, useState } from 'react'; -import { useFormik } from 'formik'; -import { useMutation, useQuery } from 'urql'; -import * as Yup from 'yup'; -import { PermissionScopeItem, usePermissionsManager } from '@/components/organization/Permissions'; -import { Button } from '@/components/ui/button'; -import { Heading } from '@/components/ui/heading'; -import { Accordion, CopyValue, Input, Modal, Tag } from '@/components/v2'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import { TargetAccessScope } from '@/gql/graphql'; -import { RegistryAccessScope } from '@/lib/access/common'; - -export const CreateAccessToken_CreateTokenMutation = graphql(` - mutation CreateAccessToken_CreateToken($input: CreateTokenInput!) { - createToken(input: $input) { - ok { - selector { - organization - project - target - } - createdToken { - id - name - alias - date - lastUsedAt - } - secret - } - error { - message - } - } - } -`); - -const CreateAccessTokenModalQuery = graphql(` - query CreateAccessTokenModalQuery($organizationId: ID!) { - organization(selector: { organization: $organizationId }) { - organization { - ...CreateAccessTokenModalContent_OrganizationFragment - } - } - } -`); - -export function CreateAccessTokenModal(props: { - isOpen: boolean; - toggleModalOpen: () => void; - organizationId: string; - projectId: string; - targetId: string; -}): ReactElement { - const { isOpen, toggleModalOpen } = props; - const [organizationQuery] = useQuery({ - query: CreateAccessTokenModalQuery, - variables: { - organizationId: props.organizationId, - }, - }); - - const organization = organizationQuery.data?.organization?.organization; - - return ( - - {organization ? ( - - ) : null} - - ); -} - -const CreateAccessTokenModalContent_OrganizationFragment = graphql(` - fragment CreateAccessTokenModalContent_OrganizationFragment on Organization { - id - ...UsePermissionManager_OrganizationFragment - me { - ...UsePermissionManager_MemberFragment - } - } -`); - -function getFinalTargetAccessScopes( - selectedScope: 'no-access' | TargetAccessScope, -): Array { - if (selectedScope === 'no-access') { - return []; - } - /** When RegistryWrite got selected, we also need to provide RegistryRead. */ - if (selectedScope === TargetAccessScope.RegistryWrite) { - return [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite]; - } - return [TargetAccessScope.RegistryRead]; -} - -function ModalContent(props: { - organization: FragmentType; - organizationId: string; - projectId: string; - targetId: string; - toggleModalOpen: () => void; -}): ReactElement { - const organization = useFragment( - CreateAccessTokenModalContent_OrganizationFragment, - props.organization, - ); - const [selectedScope, setSelectedScope] = useState( - 'no-access' as TargetAccessScope | 'no-access', - ); - - const manager = usePermissionsManager({ - onSuccess() {}, - organization, - member: organization.me, - passMemberScopes: false, - }); - - const [mutation, mutate] = useMutation(CreateAccessToken_CreateTokenMutation); - const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = - useFormik({ - initialValues: { name: '' }, - validationSchema: Yup.object().shape({ - name: Yup.string().required('Must enter description'), - }), - async onSubmit(values) { - await mutate({ - input: { - organization: props.organizationId, - project: props.projectId, - target: props.targetId, - name: values.name, - organizationScopes: [], - projectScopes: [], - targetScopes: getFinalTargetAccessScopes(selectedScope), - }, - }); - }, - }); - - const noPermissionsSelected = selectedScope === 'no-access'; - - return ( - <> - {mutation.data?.createToken.ok ? ( -
- Token successfully created! - - - This is your unique API key and it is non-recoverable. If you lose this key, you will - need to create a new one. - -
- -
- ) : ( -
-
-
- Create an access token -

- To access GraphQL Hive, your application or tool needs an active API key. -

- - -
- {touched.name && errors.name && ( -
{errors.name}
- )} - {mutation.data?.createToken.error && ( -
- {mutation.data?.createToken.error.message} -
- )} -
-
- - - Registry & Usage - - { - if (value === 'no-access') { - setSelectedScope('no-access'); - return; - } - setSelectedScope(value); - }} - possibleScope={Object.values(RegistryAccessScope.mapping)} - initialScope={selectedScope} - selectedScope={selectedScope} - /> - - - -
-
- {mutation.error &&
{mutation.error.message}
} - -
- - -
-
-
- )} - - ); -} diff --git a/packages/web/app/src/components/v2/modals/index.ts b/packages/web/app/src/components/v2/modals/index.ts index 5a057b31a..90509d734 100644 --- a/packages/web/app/src/components/v2/modals/index.ts +++ b/packages/web/app/src/components/v2/modals/index.ts @@ -1,6 +1,5 @@ export { ChangePermissionsModal } from './change-permissions'; export { ConnectSchemaModal } from './connect-schema'; -export { CreateAccessTokenModal } from './create-access-token'; export { DeleteOrganizationModal } from './delete-organization'; export { DeleteProjectModal } from './delete-project'; export { DeleteTargetModal } from './delete-target'; diff --git a/packages/web/app/src/lib/urql-cache.ts b/packages/web/app/src/lib/urql-cache.ts index 0a41557f8..04f40f81c 100644 --- a/packages/web/app/src/lib/urql-cache.ts +++ b/packages/web/app/src/lib/urql-cache.ts @@ -10,7 +10,7 @@ import type { DeleteChannelsButton_DeleteChannelsMutation } from '@/components/p import type { CreateOperationMutationType } from '@/components/target/laboratory/create-operation-modal'; import type { DeleteCollectionMutationType } from '@/components/target/laboratory/delete-collection-modal'; import type { DeleteOperationMutationType } from '@/components/target/laboratory/delete-operation-modal'; -import type { CreateAccessToken_CreateTokenMutation } from '@/components/v2/modals/create-access-token'; +import type { CreateAccessToken_CreateTokenMutation } from '@/components/target/settings/registry-access-token'; import type { DeleteOrganizationDocument } from '@/components/v2/modals/delete-organization'; import { type DeleteProjectMutation } from '@/components/v2/modals/delete-project'; import { type DeleteTargetMutation } from '@/components/v2/modals/delete-target'; diff --git a/packages/web/app/src/pages/target-settings.tsx b/packages/web/app/src/pages/target-settings.tsx index d0a5cde89..7fe515e38 100644 --- a/packages/web/app/src/pages/target-settings.tsx +++ b/packages/web/app/src/pages/target-settings.tsx @@ -7,6 +7,7 @@ import * as Yup from 'yup'; import { Page, TargetLayout } from '@/components/layouts/target'; import { SchemaEditor } from '@/components/schema-editor'; import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens'; +import { CreateAccessTokenModal } from '@/components/target/settings/registry-access-token'; import { SchemaContracts } from '@/components/target/settings/schema-contracts'; import { Button } from '@/components/ui/button'; import { CardDescription } from '@/components/ui/card'; @@ -26,7 +27,7 @@ import { TimeAgo } from '@/components/ui/time-ago'; import { useToast } from '@/components/ui/use-toast'; import { Combobox } from '@/components/v2/combobox'; import { Input } from '@/components/v2/input'; -import { CreateAccessTokenModal, DeleteTargetModal } from '@/components/v2/modals'; +import { DeleteTargetModal } from '@/components/v2/modals'; import { Switch } from '@/components/v2/switch'; import { Table, TBody, Td, Tr } from '@/components/v2/table'; import { Tag } from '@/components/v2/tag'; diff --git a/packages/web/app/src/stories/registry-access-token.stories.tsx b/packages/web/app/src/stories/registry-access-token.stories.tsx new file mode 100644 index 000000000..f4c8c3ba3 --- /dev/null +++ b/packages/web/app/src/stories/registry-access-token.stories.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { usePermissionsManager } from '@/components/organization/Permissions'; +import { + CreatedTokenContent, + GenerateTokenContent, +} from '@/components/target/settings/registry-access-token'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogTrigger } from '@/components/ui/dialog'; +import { TargetAccessScope } from '@/gql/graphql'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Modals/Create Access Token', + component: CreatedTokenContent, +}; + +export default meta; +type Story = StoryObj; + +const formSchema = z.object({ + tokenDescription: z + .string() + .min(2, { message: 'Token description must be at least 2 characters long' }), +}); + +export const GenerateToken: Story = { + render: () => { + const [openModal, setOpenModal] = useState(false); + const toggleModalOpen = () => setOpenModal(!openModal); + + const form = useForm>({ + mode: 'onChange', + resolver: zodResolver(formSchema), + defaultValues: { + tokenDescription: '', + }, + }); + + const manager = { + canAccessOrganization: () => true, + canAccessProject: () => true, + canAccessTarget: () => true, + noneSelected: false, + organizationScopes: [], + projectScopes: [], + targetScopes: [], + setOrganizationScopes: () => {}, + setProjectScopes: () => {}, + setTargetScopes: () => {}, + submit: () => {}, + } as unknown as ReturnType; + + const [selectedScope, setSelectedScope] = useState<'no-access' | TargetAccessScope>( + 'no-access', + ); + const noPermissionsSelected = selectedScope === 'no-access'; + + return ( + + + + + console.log('Submit:', values)} + selectedScope={selectedScope} + setSelectedScope={setSelectedScope} + toggleModalOpen={toggleModalOpen} + /> + + ); + }, +}; + +export const CreatedToken: Story = { + render: () => { + const [openModal, setOpenModal] = useState(false); + const toggleModalOpen = () => setOpenModal(!openModal); + + return ( + + + + + + + ); + }, +};