diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 18178824f..1056d0fa4 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -22,7 +22,7 @@ Cypress.Commands.add('signup', user => { cy.get('span[data-supertokens="link"]', { includeShadowDom: true }).contains('Sign Up').click(); cy.fillSupertokensFormAndSubmit(user); - cy.contains('Create Project'); + cy.contains('Create Organization'); }); Cypress.Commands.add('login', user => { @@ -30,5 +30,5 @@ Cypress.Commands.add('login', user => { cy.fillSupertokensFormAndSubmit(user); - cy.contains('Create Project'); + cy.contains('Create Organization'); }); diff --git a/integration-tests/tests/api/sign-up.spec.ts b/integration-tests/tests/api/sign-up.spec.ts deleted file mode 100644 index def5e38d5..000000000 --- a/integration-tests/tests/api/sign-up.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { ProjectType } from '@app/gql/graphql'; -import type { RateLimitApi } from '@hive/rate-limit'; -import { createTRPCProxyClient, httpLink } from '@trpc/client'; -import { createFetch } from '@whatwg-node/fetch'; -import { ensureEnv } from '../../testkit/env'; -import { waitFor } from '../../testkit/flow'; -import { execute } from '../../testkit/graphql'; -import { initSeed } from '../../testkit/seed'; -import { getServiceHost } from '../../testkit/utils'; -import { graphql } from './../../testkit/gql'; - -const { fetch } = createFetch({ - useNodeFetch: true, -}); - -test.concurrent('should auto-create an organization for freshly signed-up user', async () => { - const { ownerToken } = await initSeed().createOwner(); - const result = await execute({ - document: graphql(/* GraphQL */ ` - query organizations { - organizations { - total - nodes { - id - name - } - } - } - `), - authToken: ownerToken, - }).then(r => r.expectNoGraphQLErrors()); - - expect(result.organizations.total).toBe(1); -}); - -test.concurrent( - 'freshly signed-up user should have a Hobby plan with 7 days of retention', - async () => { - const { ownerToken, createPersonalProject } = await initSeed().createOwner(); - const result = await execute({ - document: graphql(/* GraphQL */ ` - query organizations { - organizations { - total - nodes { - id - name - } - } - } - `), - authToken: ownerToken, - }).then(r => r.expectNoGraphQLErrors()); - - expect(result.organizations.total).toBe(1); - - const { target } = await createPersonalProject(ProjectType.Single); - - await waitFor(ensureEnv('LIMIT_CACHE_UPDATE_INTERVAL_MS', 'number') + 1_000); // wait for rate-limit to update - - const rateLimit = createTRPCProxyClient({ - links: [ - httpLink({ - url: `http://${await getServiceHost('rate-limit', 3009)}/trpc`, - fetch, - }), - ], - }); - - // Expect the default retention for a Hobby plan to be 7 days - await expect( - rateLimit.getRetention.query({ - targetId: target.id, - }), - ).resolves.toEqual(7); - }, -); - -test.concurrent( - 'should auto-create an organization for freshly signed-up user with no race-conditions', - async () => { - const { ownerToken } = await initSeed().createOwner(); - const query1 = execute({ - document: graphql(/* GraphQL */ ` - query organizations { - organizations { - total - nodes { - id - name - } - } - } - `), - authToken: ownerToken, - }).then(r => r.expectNoGraphQLErrors()); - const query2 = execute({ - document: graphql(/* GraphQL */ ` - query organizations { - organizations { - total - nodes { - id - name - } - } - } - `), - authToken: ownerToken, - }).then(r => r.expectNoGraphQLErrors()); - - const [result1, result2] = await Promise.all([query1, query2]); - expect(result1.organizations.total).toBe(1); - expect(result2.organizations.total).toBe(1); - }, -); diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 94282d0a2..d6cc135c4 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -81,8 +81,6 @@ export interface Storage { superTokensUserId: string; externalAuthUserId?: string | null; email: string; - reservedOrgNames: string[]; - scopes: ReadonlyArray; oidcIntegration: null | { id: string; defaultScopes: Array; diff --git a/packages/services/server/src/api.ts b/packages/services/server/src/api.ts index b0ae035c1..a43f97d27 100644 --- a/packages/services/server/src/api.ts +++ b/packages/services/server/src/api.ts @@ -1,14 +1,7 @@ import { CryptoProvider } from 'packages/services/api/src/modules/shared/providers/crypto'; import { z } from 'zod'; import type { Storage } from '@hive/api'; -import { - OrganizationAccessScope, - organizationAdminScopes, - OrganizationType, - ProjectAccessScope, - reservedOrganizationNames, - TargetAccessScope, -} from '@hive/api'; +import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@hive/api'; import { FastifyRequest, handleTRPCError } from '@hive/service-common'; import type { inferAsyncReturnType } from '@trpc/server'; import { initTRPC } from '@trpc/server'; @@ -56,8 +49,6 @@ export const internalApiRouter = t.router({ .mutation(async ({ input, ctx }) => { const result = await ctx.storage.ensureUserExists({ ...input, - reservedOrgNames: reservedOrganizationNames, - scopes: organizationAdminScopes, oidcIntegration: input.oidcIntegrationId ? { id: input.oidcIntegrationId, @@ -111,13 +102,14 @@ export const internalApiRouter = t.router({ } if (user?.id) { - const organizations = await ctx.storage.getOrganizations({ user: user.id }); - const org = organizations?.find(org => org.type === OrganizationType.PERSONAL); + const allAllOraganizations = await ctx.storage.getOrganizations({ user: user.id }); + + if (allAllOraganizations.length > 0) { + const someOrg = allAllOraganizations[0]; - if (org) { return { - id: org.id, - cleanId: org.cleanId, + id: someOrg.id, + cleanId: someOrg.cleanId, }; } } diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 7e7484798..dc0ac6083 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -1,4 +1,3 @@ -import { paramCase } from 'param-case'; import { DatabasePool, DatabasePoolConnection, @@ -606,15 +605,11 @@ export async function createStorage(connection: string, maximumPoolSize: number) superTokensUserId, externalAuthUserId, email, - scopes, - reservedOrgNames, oidcIntegration, }: { superTokensUserId: string; externalAuthUserId?: string | null; email: string; - reservedOrgNames: string[]; - scopes: Parameters[0]['scopes']; oidcIntegration: null | { id: string; defaultScopes: Array; @@ -637,24 +632,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) action = 'created'; } - if (oidcIntegration === null) { - const personalOrg = await shared.getOrganization(internalUser.id, t); - - if (!personalOrg) { - await shared.createOrganization( - { - name: internalUser.displayName, - user: internalUser.id, - cleanId: paramCase(internalUser.displayName), - type: 'PERSONAL' as OrganizationType, - scopes, - reservedNames: reservedOrgNames, - }, - t, - ); - action = 'created'; - } - } else { + if (oidcIntegration !== null) { // Add user to OIDC linked integration await shared.addOrganizationMemberViaOIDCIntegrationId( { @@ -1202,7 +1180,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) return org ? transformOrganization(org) : null; }, async getOrganizations({ user }) { - const results = await pool.many>( + const results = await pool.query>( sql` SELECT o.* FROM public.organizations as o @@ -1211,7 +1189,8 @@ export async function createStorage(connection: string, maximumPoolSize: number) ORDER BY o.created_at DESC `, ); - return results.map(transformOrganization); + + return results.rows.map(transformOrganization); }, async getOrganizationByInviteCode({ inviteCode }) { const result = await pool.maybeOne>( diff --git a/packages/web/app/pages/index.tsx b/packages/web/app/pages/index.tsx index 98f946dd5..6782fd146 100644 --- a/packages/web/app/pages/index.tsx +++ b/packages/web/app/pages/index.tsx @@ -50,6 +50,13 @@ export const getServerSideProps = withSessionProtection(async ({ req, res }) => }, }; } + + return { + redirect: { + destination: '/org/new', + permanent: true, + }, + }; } } catch (error) { console.error(error); diff --git a/packages/web/app/pages/org/new.tsx b/packages/web/app/pages/org/new.tsx new file mode 100644 index 000000000..e46c26544 --- /dev/null +++ b/packages/web/app/pages/org/new.tsx @@ -0,0 +1,24 @@ +import { ReactElement } from 'react'; +import { authenticated } from '@/components/authenticated-container'; +import { SubHeader, Title } from '@/components/v2'; +import { CreateOrganizationForm } from '@/components/v2/modals/create-organization'; +import { withSessionProtection } from '@/lib/supertokens/guard'; + +function CreateOrgPage(): ReactElement { + return ( + <> + + <SubHeader> + <div className="flex items-center"> + <div className="container w-1/3"> + <CreateOrganizationForm /> + </div> + </div> + </SubHeader> + </> + ); +} + +export const getServerSideProps = withSessionProtection(); + +export default authenticated(CreateOrgPage); diff --git a/packages/web/app/src/components/v2/header.tsx b/packages/web/app/src/components/v2/header.tsx index 3b85eddad..43b756e0e 100644 --- a/packages/web/app/src/components/v2/header.tsx +++ b/packages/web/app/src/components/v2/header.tsx @@ -27,11 +27,10 @@ import { SettingsIcon, TrendingUpIcon, } from '@/components/v2/icon'; -import { CreateOrganizationModal } from '@/components/v2/modals'; import { env } from '@/env/frontend'; import { MeDocument, OrganizationsDocument, OrganizationsQuery, OrganizationType } from '@/graphql'; import { getDocsUrl } from '@/lib/docs-url'; -import { useRouteSelector, useToggle } from '@/lib/hooks'; +import { useRouteSelector } from '@/lib/hooks'; type DropdownOrganization = OrganizationsQuery['organizations']['nodes']; @@ -39,7 +38,6 @@ export function Header(): ReactElement { const router = useRouteSelector(); const [meQuery] = useQuery({ query: MeDocument }); const [organizationsQuery] = useQuery({ query: OrganizationsDocument }); - const [isModalOpen, toggleModalOpen] = useToggle(); const [isOpaque, setIsOpaque] = useState(false); const me = meQuery.data?.me; @@ -137,10 +135,12 @@ export function Header(): ReactElement { </NextLink> ))} <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={toggleModalOpen}> - <PlusIcon className="h-5 w-5" /> - Create an organization - </DropdownMenuItem> + <NextLink href="/org/new"> + <DropdownMenuItem> + <PlusIcon className="h-5 w-5" /> + Create an organization + </DropdownMenuItem> + </NextLink> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuItem asChild> @@ -200,8 +200,6 @@ export function Header(): ReactElement { </DropdownMenu> </div> </div> - - <CreateOrganizationModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} /> </header> ); } diff --git a/packages/web/app/src/components/v2/modals/create-organization.tsx b/packages/web/app/src/components/v2/modals/create-organization.tsx index 57bea931c..928662f25 100644 --- a/packages/web/app/src/components/v2/modals/create-organization.tsx +++ b/packages/web/app/src/components/v2/modals/create-organization.tsx @@ -1,9 +1,8 @@ -import { ReactElement } from 'react'; import { useRouter } from 'next/router'; import { useFormik } from 'formik'; import { useMutation } from 'urql'; import * as Yup from 'yup'; -import { Button, Heading, Input, Modal } from '@/components/v2'; +import { Button, Heading, Input } from '@/components/v2'; import { gql } from '@urql/core'; const CreateOrganizationMutation = gql(/* GraphQL */ ` @@ -28,16 +27,9 @@ const CreateOrganizationMutation = gql(/* GraphQL */ ` } `); -export const CreateOrganizationModal = ({ - isOpen, - toggleModalOpen, -}: { - isOpen: boolean; - toggleModalOpen: () => void; -}): ReactElement => { +export const CreateOrganizationForm = () => { const [mutation, mutate] = useMutation(CreateOrganizationMutation); const { push } = useRouter(); - const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = useFormik({ initialValues: { name: '' }, @@ -52,7 +44,6 @@ export const CreateOrganizationModal = ({ }); if (mutation.data?.createOrganization.ok) { - toggleModalOpen(); void push( `/${mutation.data.createOrganization.ok.createdOrganizationPayload.organization.cleanId}`, ); @@ -61,40 +52,35 @@ export const CreateOrganizationModal = ({ }); return ( - <Modal open={isOpen} onOpenChange={toggleModalOpen}> - <form onSubmit={handleSubmit} className="flex flex-col gap-5"> - <Heading className="text-center">Create an organization</Heading> - <p className="text-sm text-gray-500"> - An organization is built on top of <b>Projects</b>. You will become an <b>admin</b> and - don't worry, you can add members later. - </p> - <Input - placeholder="Organization name" - name="name" - value={values.name} - onChange={handleChange} - onBlur={handleBlur} - disabled={isSubmitting} - isInvalid={touched.name && !!errors.name} - className="grow" - /> - {touched.name && (errors.name || mutation.error) && ( - <div className="-mt-2 text-sm text-red-500">{errors.name || mutation.error?.message}</div> - )} - {mutation.data?.createOrganization.error?.inputErrors.name && ( - <div className="-mt-2 text-sm text-red-500"> - {mutation.data.createOrganization.error.inputErrors.name} - </div> - )} - <div className="flex gap-2"> - <Button type="button" size="large" block onClick={toggleModalOpen}> - Cancel - </Button> - <Button type="submit" size="large" block variant="primary" disabled={isSubmitting}> - Create Organization - </Button> + <form onSubmit={handleSubmit} className="flex flex-col gap-5"> + <Heading className="text-center">Create an organization</Heading> + <p className="text-sm text-gray-500"> + An organization is built on top of <b>Projects</b>. You will become an <b>admin</b> and + don't worry, you can add members later. + </p> + <Input + placeholder="Organization name" + name="name" + value={values.name} + onChange={handleChange} + onBlur={handleBlur} + disabled={isSubmitting} + isInvalid={touched.name && !!errors.name} + className="grow" + /> + {touched.name && (errors.name || mutation.error) && ( + <div className="-mt-2 text-sm text-red-500">{errors.name || mutation.error?.message}</div> + )} + {mutation.data?.createOrganization.error?.inputErrors.name && ( + <div className="-mt-2 text-sm text-red-500"> + {mutation.data.createOrganization.error.inputErrors.name} </div> - </form> - </Modal> + )} + <div className="flex gap-2"> + <Button type="submit" size="large" block variant="primary" disabled={isSubmitting}> + Create Organization + </Button> + </div> + </form> ); }; diff --git a/packages/web/app/src/components/v2/modals/index.ts b/packages/web/app/src/components/v2/modals/index.ts index 0fb60bd94..3ae023e62 100644 --- a/packages/web/app/src/components/v2/modals/index.ts +++ b/packages/web/app/src/components/v2/modals/index.ts @@ -3,7 +3,6 @@ export { ConnectSchemaModal } from './connect-schema'; export { CreateAccessTokenModal } from './create-access-token'; export { CreateAlertModal } from './create-alert'; export { CreateChannelModal } from './create-channel'; -export { CreateOrganizationModal } from './create-organization'; export { CreateProjectModal } from './create-project'; export { CreateTargetModal } from './create-target'; export { DeleteMembersModal } from './delete-members';