Drop modals from V2: Create Access Token Modal (#5244)

This commit is contained in:
Tuval Simha 2024-07-19 17:05:41 +03:00 committed by GitHub
parent 2b6313a564
commit ecdf2a13a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 441 additions and 254 deletions

View file

@ -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 (
<Dialog open={isOpen} onOpenChange={toggleModalOpen}>
{organization ? (
<ModalContent
organization={organization}
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
toggleModalOpen={toggleModalOpen}
/>
) : (
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
<DialogHeader>
<DialogTitle>Organization not found</DialogTitle>
<DialogDescription>
The organization you are trying to access does not exist.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={toggleModalOpen}>Ok, got it!</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
}
const CreateAccessTokenModalContent_OrganizationFragment = graphql(`
fragment CreateAccessTokenModalContent_OrganizationFragment on Organization {
id
...UsePermissionManager_OrganizationFragment
me {
...UsePermissionManager_MemberFragment
}
}
`);
function getFinalTargetAccessScopes(
selectedScope: 'no-access' | TargetAccessScope,
): Array<TargetAccessScope> {
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<typeof CreateAccessTokenModalContent_OrganizationFragment>;
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<z.infer<typeof createRegistryTokenFormSchema>>({
mode: 'onChange',
resolver: zodResolver(createRegistryTokenFormSchema),
defaultValues: {
tokenDescription: '',
},
});
const [mutation, mutate] = useMutation(CreateAccessToken_CreateTokenMutation);
async function onSubmit(values: z.infer<typeof createRegistryTokenFormSchema>) {
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 <CreatedTokenContent mutation={mutation} toggleModalOpen={props.toggleModalOpen} />;
}
return (
<GenerateTokenContent
form={form}
manager={manager}
noPermissionsSelected={noPermissionsSelected}
onSubmit={onSubmit}
selectedScope={selectedScope} // Ensure selectedScope is passed correctly
setSelectedScope={setSelectedScope}
toggleModalOpen={props.toggleModalOpen}
/>
);
}
export function CreatedTokenContent(props: {
mutation: UseMutationState<any, AnyVariables>;
toggleModalOpen: () => void;
}) {
return (
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
<DialogHeader className="flex flex-col gap-5">
<DialogTitle>Token successfully created!</DialogTitle>
<DialogDescription className="flex flex-col gap-5">
<CopyValue value={props.mutation.data.createToken.ok.secret} />
<Tag color="green">
This is your unique API key and it is non-recoverable. If you lose this key, you will
need to create a new one.
</Tag>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={props.toggleModalOpen}>Ok, got it!</Button>
</DialogFooter>
</DialogContent>
);
}
export function GenerateTokenContent(props: {
form: UseFormReturn<z.infer<typeof createRegistryTokenFormSchema>>;
onSubmit: (values: z.infer<typeof createRegistryTokenFormSchema>) => void;
manager: ReturnType<typeof usePermissionsManager>;
setSelectedScope: (scope: 'no-access' | TargetAccessScope) => void;
selectedScope: 'no-access' | TargetAccessScope;
toggleModalOpen: () => void;
noPermissionsSelected: boolean;
}) {
return (
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
<Form {...props.form}>
<form
className="flex grow flex-col gap-5"
onSubmit={props.form.handleSubmit(props.onSubmit)}
>
<DialogHeader>
<DialogTitle>Create an access token</DialogTitle>
<DialogDescription>
To access GraphQL Hive, your application or tool needs an active API key.
</DialogDescription>
</DialogHeader>
<FormField
control={props.form.control}
name="tokenDescription"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="Token description" autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<Accordion defaultValue="Permissions">
<Accordion.Item value="Permissions">
<Accordion.Header>Registry & Usage</Accordion.Header>
<Accordion.Content>
<PermissionScopeItem
key={props.selectedScope}
scope={RegistryAccessScope}
canManageScope={
props.manager.canAccessTarget(RegistryAccessScope.mapping['read-only']) ||
props.manager.canAccessTarget(RegistryAccessScope.mapping['read-write'])
}
checkAccess={props.manager.canAccessTarget}
onChange={value => {
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
/>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={props.toggleModalOpen}>
Cancel
</Button>
<Button
type="submit"
disabled={!props.form.formState.isValid || props.noPermissionsSelected}
>
Generate Token
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}

View file

@ -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 (
<Modal
open={isOpen}
onOpenChange={toggleModalOpen}
className="flex h-5/6 w-[650px] overflow-hidden"
>
{organization ? (
<ModalContent
organization={organization}
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
toggleModalOpen={toggleModalOpen}
/>
) : null}
</Modal>
);
}
const CreateAccessTokenModalContent_OrganizationFragment = graphql(`
fragment CreateAccessTokenModalContent_OrganizationFragment on Organization {
id
...UsePermissionManager_OrganizationFragment
me {
...UsePermissionManager_MemberFragment
}
}
`);
function getFinalTargetAccessScopes(
selectedScope: 'no-access' | TargetAccessScope,
): Array<TargetAccessScope> {
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<typeof CreateAccessTokenModalContent_OrganizationFragment>;
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 ? (
<div className="flex grow flex-col gap-5">
<Heading className="text-center">Token successfully created!</Heading>
<CopyValue value={mutation.data.createToken.ok.secret} />
<Tag color="green">
This is your unique API key and it is non-recoverable. If you lose this key, you will
need to create a new one.
</Tag>
<div className="grow" />
<Button variant="primary" size="lg" className="ml-auto" onClick={props.toggleModalOpen}>
Ok, got it!
</Button>
</div>
) : (
<form className="flex grow flex-col gap-5" onSubmit={handleSubmit}>
<div className="shrink-0">
<div className="flex-none">
<Heading className="mb-2 text-center">Create an access token</Heading>
<p className="mb-2 text-sm text-gray-500">
To access GraphQL Hive, your application or tool needs an active API key.
</p>
<Input
placeholder="Token description"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-full"
/>
</div>
{touched.name && errors.name && (
<div className="mt-2 text-sm text-red-500">{errors.name}</div>
)}
{mutation.data?.createToken.error && (
<div className="mt-2 text-sm text-red-500">
{mutation.data?.createToken.error.message}
</div>
)}
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<Accordion defaultValue="Permissions">
<Accordion.Item value="Permissions">
<Accordion.Header>Registry & Usage</Accordion.Header>
<Accordion.Content>
<PermissionScopeItem
scope={RegistryAccessScope}
canManageScope={
manager.canAccessTarget(RegistryAccessScope.mapping['read-only']) ||
manager.canAccessTarget(RegistryAccessScope.mapping['read-write'])
}
checkAccess={manager.canAccessTarget}
onChange={value => {
if (value === 'no-access') {
setSelectedScope('no-access');
return;
}
setSelectedScope(value);
}}
possibleScope={Object.values(RegistryAccessScope.mapping)}
initialScope={selectedScope}
selectedScope={selectedScope}
/>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</div>
<div className="shrink-0">
{mutation.error && <div className="text-sm text-red-500">{mutation.error.message}</div>}
<div className="flex w-full gap-2">
<Button
type="button"
size="lg"
className="w-full justify-center"
onClick={props.toggleModalOpen}
>
Cancel
</Button>
<Button
type="submit"
size="lg"
className="w-full justify-center"
variant="primary"
disabled={isSubmitting || noPermissionsSelected}
>
Generate Token
</Button>
</div>
</div>
</form>
)}
</>
);
}

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<typeof CreatedTokenContent> = {
title: 'Modals/Create Access Token',
component: CreatedTokenContent,
};
export default meta;
type Story = StoryObj<typeof CreatedTokenContent>;
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<z.infer<typeof formSchema>>({
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<typeof usePermissionsManager>;
const [selectedScope, setSelectedScope] = useState<'no-access' | TargetAccessScope>(
'no-access',
);
const noPermissionsSelected = selectedScope === 'no-access';
return (
<Dialog open={openModal} onOpenChange={toggleModalOpen}>
<DialogTrigger asChild>
<Button onClick={toggleModalOpen}>Open Modal</Button>
</DialogTrigger>
<GenerateTokenContent
form={form}
manager={manager}
noPermissionsSelected={noPermissionsSelected}
onSubmit={values => console.log('Submit:', values)}
selectedScope={selectedScope}
setSelectedScope={setSelectedScope}
toggleModalOpen={toggleModalOpen}
/>
</Dialog>
);
},
};
export const CreatedToken: Story = {
render: () => {
const [openModal, setOpenModal] = useState(false);
const toggleModalOpen = () => setOpenModal(!openModal);
return (
<Dialog open={openModal} onOpenChange={toggleModalOpen}>
<DialogTrigger asChild>
<Button onClick={toggleModalOpen}>Open Modal</Button>
</DialogTrigger>
<CreatedTokenContent
mutation={{
fetching: false,
data: {
createToken: {
ok: {
selector: {
organization: '',
project: '',
target: '',
},
createdToken: {
id: 'token-id',
name: 'Token Name',
alias: 'token-alias',
date: '2023-07-17',
lastUsedAt: null,
},
secret: 'token-secret',
},
},
},
stale: false,
}}
toggleModalOpen={toggleModalOpen}
/>
</Dialog>
);
},
};