mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
1️⃣ Stop creating default organization and refactor ui for org creation (#1450)
This commit is contained in:
parent
d41e5e8b2c
commit
d6d2347701
10 changed files with 82 additions and 215 deletions
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ export const getServerSideProps = withSessionProtection(async ({ req, res }) =>
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/org/new',
|
||||
permanent: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
|
|||
24
packages/web/app/pages/org/new.tsx
Normal file
24
packages/web/app/pages/org/new.tsx
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue