1️⃣ Stop creating default organization and refactor ui for org creation (#1450)

This commit is contained in:
Dotan Simha 2023-02-22 21:03:01 +09:00 committed by GitHub
parent d41e5e8b2c
commit d6d2347701
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 82 additions and 215 deletions

View file

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

View file

@ -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<RateLimitApi>({
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);
},
);

View file

@ -81,8 +81,6 @@ export interface Storage {
superTokensUserId: string;
externalAuthUserId?: string | null;
email: string;
reservedOrgNames: string[];
scopes: ReadonlyArray<OrganizationAccessScope | ProjectAccessScope | TargetAccessScope>;
oidcIntegration: null | {
id: string;
defaultScopes: Array<OrganizationAccessScope | ProjectAccessScope | TargetAccessScope>;

View file

@ -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,
};
}
}

View file

@ -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<Storage['createOrganization']>[0]['scopes'];
oidcIntegration: null | {
id: string;
defaultScopes: Array<OrganizationAccessScope | ProjectAccessScope | TargetAccessScope>;
@ -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<Slonik<organizations>>(
const results = await pool.query<Slonik<organizations>>(
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<Slonik<organizations>>(

View file

@ -50,6 +50,13 @@ export const getServerSideProps = withSessionProtection(async ({ req, res }) =>
},
};
}
return {
redirect: {
destination: '/org/new',
permanent: true,
},
};
}
} catch (error) {
console.error(error);

View file

@ -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 (
<>
<Title title="Create Organization" />
<SubHeader>
<div className="flex items-center">
<div className="container w-1/3">
<CreateOrganizationForm />
</div>
</div>
</SubHeader>
</>
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(CreateOrgPage);

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

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