2️⃣ Drop Personal organizations (#1452)

This commit is contained in:
Dotan Simha 2023-02-28 16:52:37 +09:00 committed by GitHub
parent 2ce7e73cac
commit 627df54206
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 293 additions and 266 deletions

38
.github/workflows/migrations-test.yaml vendored Normal file
View file

@ -0,0 +1,38 @@
on:
workflow_call:
jobs:
test:
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:14.6-alpine
ports:
- 5432:5432
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: registry
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: registry
steps:
- name: checkout
uses: actions/checkout@v3
- name: setup environment
uses: ./.github/actions/setup
with:
codegen: false # no need to run codegen in this case, we can skip
- name: migrations tests
run: pnpm test
working-directory: packages/migrations

View file

@ -16,6 +16,10 @@ jobs:
db-types:
uses: ./.github/workflows/db-types-diff.yaml
# Run migrations tests
db-migrations:
uses: ./.github/workflows/migrations-test.yaml
# GraphQL Breaking Changes Check
# This workflow validates that the GraphQL schema is not breaking, and fails in case of a breaking change.
# To allow a GraphQL breaking change in a PR, you may add the "non-breaking" label to the PR.

View file

@ -12,6 +12,7 @@ __generated__/
/packages/web/app/src/gql/**
/packages/web/app/src/graphql/index.ts
/packages/web/app/next.config.mjs
/packages/migrations/test/utils/testkit.ts
# test fixtures
integration-tests/fixtures/init-invalid-schema.graphql

View file

@ -11,7 +11,7 @@ const config = {
plugins: [
{
add: {
content: "import { StripeTypes } from '@hive/stripe-billing';",
content: "import type { StripeTypes } from '@hive/stripe-billing';",
},
},
'typescript',
@ -26,7 +26,6 @@ const config = {
immutableTypes: true,
contextType: 'GraphQLModules.ModuleContext',
enumValues: {
OrganizationType: '../shared/entities#OrganizationType',
ProjectType: '../shared/entities#ProjectType',
TargetAccessScope: '../modules/auth/providers/target-access#TargetAccessScope',
ProjectAccessScope: '../modules/auth/providers/project-access#ProjectAccessScope',

View file

@ -82,7 +82,6 @@ export function getOrganization(organizationId: string, authToken: string) {
id
cleanId
name
type
getStarted {
creatingProject
publishingSchema

View file

@ -1,6 +1,5 @@
import {
OrganizationAccessScope,
OrganizationType,
ProjectAccessScope,
ProjectType,
RegistryModel,
@ -35,8 +34,6 @@ import {
updateRegistryModel,
updateSchemaVersionStatus,
} from './flow';
import { graphql } from './gql';
import { execute } from './graphql';
import { collect, CollectedOperation } from './usage';
import { generateUnique } from './utils';
@ -51,48 +48,6 @@ export function initSeed() {
return {
ownerEmail,
ownerToken,
async createPersonalProject(projectType: ProjectType) {
const orgs = await execute({
document: graphql(`
query myOrganizations {
organizations {
total
nodes {
id
cleanId
name
type
}
}
}
`),
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
const personalOrg = orgs.organizations.nodes.find(
o => o.type === OrganizationType.Personal,
);
if (!personalOrg) {
throw new Error('Personal organization should exist');
}
const projectResult = await createProject(
{
organization: personalOrg.cleanId,
type: projectType,
name: generateUnique(),
},
ownerToken,
).then(r => r.expectNoGraphQLErrors());
const targets = projectResult.createProject.ok!.createdTargets;
const target = targets[0];
return {
target,
};
},
async createOrg() {
const orgName = generateUnique();
const orgResult = await createOrganization({ name: orgName }, ownerToken).then(r =>

View file

@ -15,7 +15,8 @@
"db:migrator": "node --experimental-specifier-resolution=node --loader ts-node/esm src/index.ts",
"migration:create": "pnpm db:migrator create",
"migration:rollback": "pnpm db:migrator down",
"migration:run": "pnpm db:migrator up"
"migration:run": "pnpm db:migrator up",
"test": "node --experimental-specifier-resolution=node --loader ts-node/esm ./test/*.test.ts"
},
"dependencies": {
"@slonik/migrator": "0.11.3",

View file

@ -0,0 +1,13 @@
-- Find and delete all organizations of type PERSONAL that have no projects
DELETE FROM public.organizations as o
WHERE
o.type = 'PERSONAL'
AND NOT EXISTS (
SELECT id from public.projects as p WHERE p.org_id = o.id LIMIT 1
);
-- Delete the "type" column from organizations
ALTER TABLE public.organizations DROP COLUMN type;
-- Delete the "organization_type" enum, as it's unused now
DROP TYPE organization_type;

View file

@ -0,0 +1 @@
raise 'down migration not implemented'

View file

@ -0,0 +1,11 @@
export function createConnectionString(config: {
host: string;
port: number;
password: string;
user: string;
db: string;
ssl: boolean;
}) {
// prettier-ignore
return `postgres://${config.user}:${config.password}@${config.host}:${config.port}/${config.db}${config.ssl ? '?sslmode=require' : '?sslmode=disable'}`;
}

View file

@ -4,20 +4,9 @@ import url from 'node:url';
import { createPool } from 'slonik';
import { SlonikMigrator } from '@slonik/migrator';
import { migrateClickHouse } from './clickhouse';
import { createConnectionString } from './connection-string';
import { env } from './environment';
export function createConnectionString(config: {
host: string;
port: number;
password: string;
user: string;
db: string;
ssl: boolean;
}) {
// prettier-ignore
return `postgres://${config.user}:${config.password}@${config.host}:${config.port}/${config.db}${config.ssl ? '?sslmode=require' : '?sslmode=disable'}`;
}
const [, , cmd] = process.argv;
const slonik = await createPool(createConnectionString(env.postgres));

View file

@ -0,0 +1,69 @@
import assert from 'node:assert';
import { describe, test } from 'node:test';
import { sql } from 'slonik';
import { initMigrationTestingEnvironment } from './utils/testkit';
describe('migration: drop-personal-org', async () => {
await test('should remove all existing personal orgs that does not have projects', async () => {
const { db, runTo, complete, done, seed } = await initMigrationTestingEnvironment();
try {
// Run migrations all the way to the point before the one we are testing
await runTo('2023.02.21T14.32.24.supertokens-4.0.0.sql');
// Seed the DB with orgs
const user = await seed.user();
const emptyOrgs = await Promise.all([
db.one(
sql`INSERT INTO public.organizations (clean_id, name, user_id, type) VALUES ('personal-empty', 'personal-empty', ${user.id}, 'PERSONAL') RETURNING *;`,
),
db.one(
sql`INSERT INTO public.organizations (clean_id, name, user_id, type) VALUES ('regular-empty', 'regular-empty', ${user.id}, 'REGULAR') RETURNING *;`,
),
]);
const orgsWithProjects = await Promise.all([
await db.one(
sql`INSERT INTO public.organizations (clean_id, name, user_id, type) VALUES ('personal-project', 'personal-project', ${user.id}, 'PERSONAL') RETURNING *;`,
),
await db.one(
sql`INSERT INTO public.organizations (clean_id, name, user_id, type) VALUES ('regular-project', 'regular-project', ${user.id}, 'PERSONAL') RETURNING *;`,
),
]);
// Seed with projects
await db.one(
sql`INSERT INTO public.projects (clean_id, name, type, org_id) VALUES ('proj-1', 'proj-1', 'SINGLE', ${orgsWithProjects[0].id}) RETURNING *;`,
);
await db.one(
sql`INSERT INTO public.projects (clean_id, name, type, org_id) VALUES ('proj-2', 'proj-2', 'SINGLE', ${orgsWithProjects[1].id}) RETURNING *;`,
);
// Run the additional remaining migrations
await complete();
// Only this one should be deleted, the rest should still exists
assert.equal(
await db.maybeOne(sql`SELECT * FROM public.organizations WHERE id = ${emptyOrgs[0].id}`),
null,
);
assert.notEqual(
await db.maybeOne(sql`SELECT * FROM public.organizations WHERE id = ${emptyOrgs[1].id}`),
null,
);
assert.notEqual(
await db.maybeOne(
sql`SELECT * FROM public.organizations WHERE id = ${orgsWithProjects[0].id}`,
),
null,
);
assert.notEqual(
await db.maybeOne(
sql`SELECT * FROM public.organizations WHERE id = ${orgsWithProjects[1].id}`,
),
null,
);
} finally {
await done();
}
});
});

View file

@ -0,0 +1,71 @@
/* eslint-disable import/first */
/* eslint-disable import/no-extraneous-dependencies */
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import pgpFactory from 'pg-promise';
import { createPool, sql } from 'slonik';
import { SlonikMigrator } from '@slonik/migrator';
import type * as DbTypes from '../../../services/storage/src/db/types';
import { createConnectionString } from '../../src/connection-string';
export { DbTypes };
const __dirname = dirname(fileURLToPath(import.meta.url));
config({
path: resolve(__dirname, '../../.env'),
});
import { env } from '../../src/environment';
export async function initMigrationTestingEnvironment() {
const pgp = pgpFactory();
const db = pgp(
createConnectionString({
...env.postgres,
db: 'postgres',
}),
);
const dbName = 'migration_test_' + Date.now();
await db.query(`CREATE DATABASE ${dbName};`);
const slonik = await createPool(
createConnectionString({
...env.postgres,
db: dbName,
}),
);
const actionsDirectory = resolve(__dirname + '/../../src/actions/');
console.log('actionsDirectory', actionsDirectory);
const migrator = new SlonikMigrator({
migrationsPath: actionsDirectory,
slonik,
migrationTableName: 'migration',
logger: console,
});
return {
db: slonik,
async runTo(name: string) {
await migrator.up({ to: name });
},
seed: {
async user() {
return await slonik.one<DbTypes.users>(
sql`INSERT INTO public.users (email, display_name, full_name, supertoken_user_id) VALUES ('test@mail.com', 'test1' , 'test1', '1') RETURNING *;`,
);
},
},
async complete() {
await migrator.up();
},
async done(deleteDb = true) {
deleteDb ?? (await db.query(`DROP DATABASE ${dbName};`));
await db.$pool.end().catch();
},
};
}

View file

@ -23,7 +23,7 @@ export type {
} from './shared/entities';
export { minifySchema } from './shared/schema';
export { HiveError } from './shared/errors';
export { OrganizationType, ProjectType } from './__generated__/types';
export { ProjectType } from './__generated__/types';
export type { AuthProvider } from './__generated__/types';
export { HttpClient } from './modules/shared/providers/http-client';
export { OperationsManager } from './modules/operations/providers/operations-manager';

View file

@ -1,6 +1,6 @@
import { Inject, Injectable, Scope } from 'graphql-modules';
import zod from 'zod';
import { OIDCIntegration, OrganizationType } from '../../../shared/entities';
import { OIDCIntegration } from '../../../shared/entities';
import { AuthManager } from '../../auth/providers/auth-manager';
import { OrganizationAccessScope } from '../../auth/providers/organization-access';
import { CryptoProvider } from '../../shared/providers/crypto';
@ -29,17 +29,14 @@ export class OIDCIntegrationsProvider {
return this.enabled;
}
async canViewerManageIntegrationForOrganization(args: {
organizationId: string;
organizationType: OrganizationType;
}) {
if (this.isEnabled() === false || args.organizationType === OrganizationType.PERSONAL) {
async canViewerManageIntegrationForOrganization(organizationId: string) {
if (this.isEnabled() === false) {
return false;
}
try {
await this.authManager.ensureOrganizationAccess({
organization: args.organizationId,
organization: organizationId,
scope: OrganizationAccessScope.INTEGRATIONS,
});
return true;
@ -97,11 +94,8 @@ export class OIDCIntegrationsProvider {
const organization = await this.storage.getOrganization({ organization: args.organizationId });
if (organization.type === OrganizationType.PERSONAL) {
return {
type: 'error',
message: 'Personal organizations cannot have OIDC integrations.',
} as const;
if (!organization) {
throw new Error(`Failed to locate organization ${args.organizationId}`);
}
const clientIdResult = OIDCIntegrationClientIdModel.safeParse(args.clientId);

View file

@ -98,10 +98,9 @@ export const resolvers: OidcIntegrationsModule.Resolvers = {
},
Organization: {
viewerCanManageOIDCIntegration: (organization, _, { injector }) => {
return injector.get(OIDCIntegrationsProvider).canViewerManageIntegrationForOrganization({
organizationId: organization.id,
organizationType: organization.type,
});
return injector
.get(OIDCIntegrationsProvider)
.canViewerManageIntegrationForOrganization(organization.id);
},
oidcIntegration: async (organization, _, { injector }) => {
if (injector.get(OIDCIntegrationsProvider).isEnabled() === false) {

View file

@ -175,16 +175,10 @@ export default gql`
organization: Organization!
}
enum OrganizationType {
PERSONAL
REGULAR
}
type Organization {
id: ID!
cleanId: ID!
name: String!
type: OrganizationType!
owner: Member!
me: Member!
members: MemberConnection!

View file

@ -1,7 +1,7 @@
import { createHash } from 'crypto';
import { Inject, Injectable, Scope } from 'graphql-modules';
import { paramCase } from 'param-case';
import { Organization, OrganizationInvitation, OrganizationType } from '../../../shared/entities';
import { Organization, OrganizationInvitation } from '../../../shared/entities';
import { HiveError } from '../../../shared/errors';
import { cache, diffArrays, pushIfMissing, share, uuid } from '../../../shared/helpers';
import { ActivityManager } from '../../activity/providers/activity-manager';
@ -140,14 +140,13 @@ export class OrganizationManager {
async createOrganization(input: {
name: string;
type: OrganizationType;
user: {
id: string;
superTokensUserId: string | null;
oidcIntegrationId: string | null;
};
}): Promise<Organization> {
const { name, type, user } = input;
const { name, user } = input;
this.logger.info('Creating an organization (input=%o)', input);
if (user.oidcIntegrationId) {
@ -161,7 +160,6 @@ export class OrganizationManager {
const organization = await this.storage.createOrganization({
name,
cleanId: paramCase(name),
type,
user: user.id,
scopes: organizationAdminScopes,
reservedNames: reservedOrganizationNames,
@ -189,10 +187,6 @@ export class OrganizationManager {
organization: selector.organization,
});
if (organization.type === OrganizationType.PERSONAL) {
throw new HiveError(`Cannot remove a personal organization`);
}
const deletedOrganization = await this.storage.deleteOrganization({
organization: organization.id,
});
@ -287,10 +281,6 @@ export class OrganizationManager {
}),
]);
if (organization.type === OrganizationType.PERSONAL) {
throw new HiveError(`Cannot rename a personal organization`);
}
let cleanId = paramCase(name);
if (await this.storage.getOrganizationByCleanId({ cleanId })) {
@ -344,10 +334,6 @@ export class OrganizationManager {
organization: input.organization,
});
if (organization.type === OrganizationType.PERSONAL) {
throw new HiveError(`Cannot invite to a personal organization`);
}
const members = await this.getOrganizationMembers({ organization: input.organization });
const existingMember = members.find(member => member.user.email === email);
@ -425,10 +411,6 @@ export class OrganizationManager {
return organization;
}
if (organization.type === OrganizationType.PERSONAL) {
throw new HiveError(`Cannot join a personal organization`);
}
await this.storage.addOrganizationMemberViaInvitationCode({
code,
user: user.id,
@ -493,14 +475,6 @@ export class OrganizationManager {
const organization = await this.getOrganization(selector);
if (organization.type === OrganizationType.PERSONAL) {
return {
error: {
message: `Personal organizations cannot be transferred`,
},
};
}
const { code } = await this.storage.createOrganizationTransferRequest({
organization: organization.id,
user: member.id,

View file

@ -1,5 +1,4 @@
import { z } from 'zod';
import { OrganizationType } from '../../shared/entities';
import { createConnection } from '../../shared/schema';
import { AuthManager } from '../auth/providers/auth-manager';
import { IdTranslator } from '../shared/providers/id-translator';
@ -80,7 +79,6 @@ export const resolvers: OrganizationModule.Resolvers = {
const user = await injector.get(AuthManager).getCurrentUser();
const organization = await injector.get(OrganizationManager).createOrganization({
name: input.name,
type: OrganizationType.REGULAR,
user,
});

View file

@ -109,7 +109,7 @@ export interface Storage {
getMyOrganization(_: { user: string }): Promise<Organization | null>;
getOrganizations(_: { user: string }): Promise<readonly Organization[] | never>;
createOrganization(
_: Pick<Organization, 'cleanId' | 'name' | 'type'> & {
_: Pick<Organization, 'cleanId' | 'name'> & {
user: string;
scopes: ReadonlyArray<OrganizationAccessScope | ProjectAccessScope | TargetAccessScope>;
reservedNames: string[];

View file

@ -124,11 +124,6 @@ export enum ProjectType {
SINGLE = 'SINGLE',
}
export enum OrganizationType {
PERSONAL = 'PERSONAL',
REGULAR = 'REGULAR',
}
export interface OrganizationGetStarted {
id: string;
creatingProject: boolean;
@ -143,7 +138,6 @@ export interface Organization {
id: string;
cleanId: string;
name: string;
type: OrganizationType;
billingPlan: string;
monthlyRateLimit: {
retentionInDays: number;

View file

@ -10,7 +10,6 @@
export type alert_channel_type = "SLACK" | "WEBHOOK";
export type alert_type = "SCHEMA_CHANGE_NOTIFICATIONS";
export type operation_kind = "mutation" | "query" | "subscription";
export type organization_type = "PERSONAL" | "REGULAR";
export type user_role = "ADMIN" | "MEMBER";
export interface activities {
@ -106,7 +105,6 @@ export interface organizations {
ownership_transfer_user_id: string | null;
plan_name: string;
slack_token: string | null;
type: organization_type;
user_id: string;
}

View file

@ -18,7 +18,6 @@ import type {
OrganizationAccessScope,
OrganizationBilling,
OrganizationInvitation,
OrganizationType,
PersistedOperation,
Project,
ProjectAccessScope,
@ -167,7 +166,6 @@ export async function createStorage(connection: string, maximumPoolSize: number)
operations: parseInt(organization.limit_operations_monthly),
},
billingPlan: organization.plan_name,
type: (organization.type === 'PERSONAL' ? 'PERSONAL' : 'REGULAR') as OrganizationType,
getStarted: {
id: organization.id,
creatingProject: organization.get_started_creating_project,
@ -488,7 +486,6 @@ export async function createStorage(connection: string, maximumPoolSize: number)
name,
user,
cleanId,
type,
scopes,
reservedNames,
}: Parameters<Storage['createOrganization']>[0] & {
@ -520,9 +517,9 @@ export async function createStorage(connection: string, maximumPoolSize: number)
const org = await connection.one<Slonik<organizations>>(
sql`
INSERT INTO public.organizations
("name", "clean_id", "type", "user_id")
("name", "clean_id", "user_id")
VALUES
(${name}, ${availableCleanId}, ${type}, ${user})
(${name}, ${availableCleanId}, ${user})
RETURNING *
`,
);

View file

@ -38,7 +38,6 @@ const ProjectCard_ProjectFragment = graphql(`
fragment ProjectCard_ProjectFragment on Project {
cleanId
id
type
name
}
`);
@ -83,7 +82,6 @@ const ProjectCard = (props: {
>
<div className="flex items-start gap-x-2">
<div className="grow">
<h3 className="text-xs font-medium text-[#34EAB9]">{project.type}</h3>
<h4 className="line-clamp-2 text-lg font-bold">{project.name}</h4>
</div>

View file

@ -14,7 +14,7 @@ import {
import { CopyIcon, KeyIcon, MoreIcon, SettingsIcon, TrashIcon } from '@/components/v2/icon';
import { ChangePermissionsModal, DeleteMembersModal } from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { MeDocument, OrganizationFieldsFragment, OrganizationType } from '@/graphql';
import { MeDocument, OrganizationFieldsFragment } from '@/graphql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { useClipboard } from '@/lib/hooks/use-clipboard';
import { useNotifications } from '@/lib/hooks/use-notifications';
@ -217,7 +217,6 @@ const Page_OrganizationFragment = graphql(`
...ChangePermissionsModal_MemberFragment
}
cleanId
type
owner {
id
}
@ -281,21 +280,18 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
const [meQuery] = useQuery({ query: MeDocument });
const router = useRouteSelector();
const org = organization;
const isPersonal = org?.type === OrganizationType.Personal;
const members = org?.members.nodes;
const members = organization?.members.nodes;
useEffect(() => {
if (isPersonal) {
void router.replace(`/${router.organizationId}`);
} else if (members) {
if (members) {
// uncheck checkboxes when members were deleted
setChecked(prev => prev.filter(id => members.some(node => node.id === id)));
}
}, [isPersonal, router, members]);
}, [router, members]);
if (!org || isPersonal) return null;
if (!organization) {
return null;
}
const me = meQuery.data?.me;
const selectedMember = selectedMemberId
@ -311,7 +307,7 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
<ChangePermissionsModal
isOpen={isPermissionsModalOpen}
toggleModalOpen={togglePermissionsModalOpen}
organization={org}
organization={organization}
member={selectedMember}
/>
)}
@ -321,7 +317,7 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
memberIds={checked}
/>
<div className="flex items-center justify-between">
<MemberInvitationForm organizationCleanId={org.cleanId} />
<MemberInvitationForm organizationCleanId={organization.cleanId} />
<Button
size="large"
danger
@ -336,7 +332,7 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
{members?.map(node => {
const IconToUse = KeyIcon;
const isOwner = node.id === org.owner.id;
const isOwner = node.id === organization.owner.id;
const isMe = node.id === me?.id;
const isDisabled = isOwner || isMe;
@ -386,7 +382,7 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
</Card>
);
})}
<OrganizationInvitations organization={org} />
<OrganizationInvitations organization={organization} />
</>
);
}

View file

@ -17,7 +17,6 @@ import {
CheckIntegrationsDocument,
DeleteGitHubIntegrationDocument,
DeleteSlackIntegrationDocument,
OrganizationType,
} from '@/graphql';
import {
canAccessOrganization,
@ -144,7 +143,6 @@ const UpdateOrganizationNameMutation = graphql(`
const SettingsPageRenderer_OrganizationFragment = graphql(`
fragment SettingsPageRenderer_OrganizationFragment on Organization {
type
name
me {
...CanAccessOrganization_MemberFragment
@ -165,7 +163,6 @@ const SettingsPageRenderer = (props: {
redirect: true,
});
const router = useRouteSelector();
const isRegularOrg = organization?.type === OrganizationType.Regular;
const [isDeleteModalOpen, toggleDeleteModalOpen] = useToggle();
const [isTransferModalOpen, toggleTransferModalOpen] = useToggle();
@ -198,46 +195,44 @@ const SettingsPageRenderer = (props: {
return (
<>
{isRegularOrg && (
<Card>
<Heading className="mb-2">Organization Name</Heading>
<p className="mb-3 font-light text-gray-300">
Name of your organization visible within Hive
</p>
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Organization name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
<Button
type="submit"
variant="primary"
size="large"
disabled={isSubmitting}
className="px-10"
>
Save
</Button>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">{errors.name || mutation.error?.message}</div>
)}
{mutation.data?.updateOrganizationName?.error && (
<div className="mt-2 text-red-500">
{mutation.data?.updateOrganizationName.error.message}
</div>
)}
{mutation.error && (
<div>{mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}</div>
)}
</Card>
)}
<Card>
<Heading className="mb-2">Organization Name</Heading>
<p className="mb-3 font-light text-gray-300">
Name of your organization visible within Hive
</p>
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Organization name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
<Button
type="submit"
variant="primary"
size="large"
disabled={isSubmitting}
className="px-10"
>
Save
</Button>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">{errors.name || mutation.error?.message}</div>
)}
{mutation.data?.updateOrganizationName?.error && (
<div className="mt-2 text-red-500">
{mutation.data?.updateOrganizationName.error.message}
</div>
)}
{mutation.error && (
<div>{mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}</div>
)}
</Card>
{canAccessOrganization(OrganizationAccessScope.Integrations, organization.me) && (
<Card>
@ -249,7 +244,7 @@ const SettingsPageRenderer = (props: {
</Card>
)}
{isRegularOrg && organization.me.isOwner && (
{organization.me.isOwner && (
<Card>
<div className="flex items-center justify-between">
<div>
@ -276,7 +271,7 @@ const SettingsPageRenderer = (props: {
</Card>
)}
{isRegularOrg && canAccessOrganization(OrganizationAccessScope.Delete, organization.me) && (
{canAccessOrganization(OrganizationAccessScope.Delete, organization.me) && (
<Card>
<div className="flex items-center justify-between">
<div>

View file

@ -8,7 +8,7 @@ import { Title } from '@/components/common';
import { DataWrapper } from '@/components/v2';
import { LAST_VISITED_ORG_KEY } from '@/constants';
import { env } from '@/env/backend';
import { OrganizationsDocument, OrganizationType } from '@/graphql';
import { OrganizationsDocument } from '@/graphql';
import { writeLastVisitedOrganization } from '@/lib/cookies';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
@ -74,9 +74,8 @@ function Home(): ReactElement {
useEffect(() => {
// Just in case server-side redirect wasn't working
if (query.data) {
const org = query.data.organizations.nodes.find(
node => node.type === OrganizationType.Personal,
);
const org = query.data.organizations.nodes[0];
if (org) {
router.visitOrganization({ organizationId: org.cleanId });
}

View file

@ -18,11 +18,6 @@ const dateRangeOptions = DATE_RANGE_OPTIONS.filter(isNotAllOption);
type FilterKey = keyof Filters;
const CHECKBOXES: { value: FilterKey; label: string; tooltip?: string }[] = [
{
value: 'only-regular',
label: 'Only Regular',
tooltip: 'Do not count personal organizations, created automatically for every user',
},
{ value: 'with-projects', label: 'With Projects' },
{ value: 'with-targets', label: 'With Targets' },
{ value: 'with-schema-pushes', label: 'With Schema Pushes' },
@ -38,7 +33,6 @@ function Manage() {
const newFilters: {
[key in FilterKey]: boolean;
} = {
'only-regular': false,
'with-collected': false,
'with-schema-pushes': false,
'with-persisted': false,

View file

@ -16,7 +16,6 @@ import { Button, DataWrapper, Stat, Table, TBody, Td, Th, THead, Tr } from '@/co
import { CHART_PRIMARY_COLOR } from '@/constants';
import { env } from '@/env/frontend';
import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationType } from '@/graphql';
import { theme } from '@/lib/charts';
import { useChartStyles } from '@/utils';
import { ChevronUpIcon } from '@radix-ui/react-icons';
@ -33,7 +32,6 @@ import {
interface Organization {
name: ReactElement;
members: string;
type: OrganizationType;
users: number;
projects: number;
targets: number;
@ -58,7 +56,6 @@ function sumByKey<
}
export type Filters = Partial<{
'only-regular': boolean;
'with-projects': boolean;
'with-targets': boolean;
'with-schema-pushes': boolean;
@ -152,7 +149,6 @@ const AdminStatsQuery = graphql(`
id
cleanId
name
type
owner {
user {
email
@ -188,10 +184,6 @@ function filterStats(
row: DocumentType<typeof AdminStatsQuery>['admin']['stats']['organizations'][0],
filters: Filters,
) {
if (filters['only-regular'] && row.organization.type !== 'REGULAR') {
return false;
}
if (filters['with-projects'] && row.projects === 0) {
return false;
}
@ -221,10 +213,6 @@ const columns = [
footer: props => props.column.id,
enableSorting: false,
}),
table.createDataColumn('type', {
header: 'Type',
footer: props => props.column.id,
}),
table.createDataColumn('members', {
header: 'Members',
footer: props => props.column.id,
@ -412,7 +400,6 @@ export function AdminStats({
</div>
),
members: (node.organization.members.nodes || []).map(v => v.user.email).join(', '),
type: node.organization.type,
users: node.users,
projects: node.projects,
targets: node.targets,
@ -425,7 +412,7 @@ export function AdminStats({
const overall = useMemo(
() => ({
users: tableData.reduce((total, node) => (node.type === 'PERSONAL' ? total + 1 : total), 0),
users: sumByKey(tableData, 'users'),
organizations: tableData.length,
projects: sumByKey(tableData, 'projects'),
targets: sumByKey(tableData, 'targets'),

View file

@ -2,7 +2,6 @@ import { ReactElement, ReactNode } from 'react';
import clsx from 'clsx';
import { Drawer } from '@/components/v2';
import { DocumentType, graphql } from '@/gql';
import { OrganizationType } from '@/graphql';
import { getDocsUrl } from '@/lib/docs-url';
import { useToggle } from '@/lib/hooks';
import { CheckCircledIcon } from '@radix-ui/react-icons';
@ -20,10 +19,8 @@ const GetStartedWizard_GetStartedProgress = graphql(`
export function GetStartedProgress({
tasks,
organizationType,
}: {
tasks: DocumentType<typeof GetStartedWizard_GetStartedProgress>;
organizationType: OrganizationType;
}): ReactElement | null {
const [isOpen, toggle] = useToggle();
@ -31,14 +28,7 @@ export function GetStartedProgress({
return null;
}
const processedTasks =
organizationType === OrganizationType.Personal
? {
...tasks,
invitingMembers: undefined,
}
: tasks;
const values = Object.values(processedTasks).filter(v => typeof v === 'boolean');
const values = Object.values(tasks).filter(v => typeof v === 'boolean');
const total = values.length;
const completed = values.filter(t => t === true).length;
const remaining = total - completed;
@ -66,7 +56,7 @@ export function GetStartedProgress({
</div>
</div>
</button>
<GetStartedWizard isOpen={isOpen} onClose={toggle} tasks={processedTasks} />
<GetStartedWizard isOpen={isOpen} onClose={toggle} tasks={tasks} />
</>
);
}

View file

@ -8,7 +8,7 @@ import { PlusIcon } from '@/components/v2/icon';
import { CreateProjectModal } from '@/components/v2/modals';
import { LAST_VISITED_ORG_KEY } from '@/constants';
import { FragmentType, graphql, useFragment } from '@/gql';
import { Exact, OrganizationType } from '@/graphql';
import { Exact } from '@/graphql';
import {
canAccessOrganization,
OrganizationAccessScope,
@ -26,7 +26,6 @@ enum TabValue {
const OrganizationLayout_OrganizationFragment = graphql(`
fragment OrganizationLayout_OrganizationFragment on Organization {
type
name
me {
...CanAccessOrganization_MemberFragment
@ -100,7 +99,6 @@ export function OrganizationLayout<
}
const me = organization?.me;
const isRegularOrg = !organization || organization.type === OrganizationType.Regular;
if (!organization || !me) {
return null;
@ -133,7 +131,7 @@ export function OrganizationLayout<
<Tabs.Trigger value={TabValue.Overview} asChild>
<NextLink href={`/${orgId}`}>Overview</NextLink>
</Tabs.Trigger>
{isRegularOrg && canAccessOrganization(OrganizationAccessScope.Members, me) && (
{canAccessOrganization(OrganizationAccessScope.Members, me) && (
<Tabs.Trigger value={TabValue.Members} asChild>
<NextLink href={`/${orgId}/view/${TabValue.Members}`}>Members</NextLink>
</Tabs.Trigger>

View file

@ -28,12 +28,10 @@ import {
TrendingUpIcon,
} from '@/components/v2/icon';
import { env } from '@/env/frontend';
import { MeDocument, OrganizationsDocument, OrganizationsQuery, OrganizationType } from '@/graphql';
import { MeDocument, OrganizationsDocument } from '@/graphql';
import { getDocsUrl } from '@/lib/docs-url';
import { useRouteSelector } from '@/lib/hooks';
type DropdownOrganization = OrganizationsQuery['organizations']['nodes'];
export function Header(): ReactElement {
const router = useRouteSelector();
const [meQuery] = useQuery({ query: MeDocument });
@ -41,25 +39,11 @@ export function Header(): ReactElement {
const [isOpaque, setIsOpaque] = useState(false);
const me = meQuery.data?.me;
const allOrgs = organizationsQuery.data?.organizations.nodes || [];
const { personal, organizations } = allOrgs.reduce<{
personal: DropdownOrganization;
organizations: DropdownOrganization;
}>(
(acc, node) => {
if (node.type === OrganizationType.Personal) {
acc.personal.push(node);
} else {
acc.organizations.push(node);
}
return acc;
},
{ personal: [], organizations: [] },
);
const organizations = organizationsQuery.data?.organizations.nodes || [];
const currentOrg =
typeof router.organizationId === 'string'
? allOrgs.find(org => org.cleanId === router.organizationId)
? organizations.find(org => org.cleanId === router.organizationId)
: null;
// Copied from tailwindcss website
@ -92,9 +76,7 @@ export function Header(): ReactElement {
<div className="container flex h-[84px] items-center justify-between">
<HiveLink />
<div className="flex flex-row gap-8">
{currentOrg ? (
<GetStartedProgress organizationType={currentOrg.type} tasks={currentOrg.getStarted} />
) : null}
{currentOrg ? <GetStartedProgress tasks={currentOrg.getStarted} /> : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
@ -116,17 +98,9 @@ export function Header(): ReactElement {
</DropdownMenuSubTrigger>
) : null}
<DropdownMenuSubContent sideOffset={25} className="max-w-[300px]">
<DropdownMenuLabel className="px-2 mb-2 text-xs font-bold text-gray-500">
PERSONAL
</DropdownMenuLabel>
{personal.map(org => (
<NextLink href={`/${org.cleanId}`} key={org.cleanId}>
<DropdownMenuItem className="truncate !block">{org.name}</DropdownMenuItem>
</NextLink>
))}
{organizations.length ? (
<DropdownMenuLabel className="px-2 mb-2 text-xs font-bold text-gray-500">
OUTERS ORGANIZATIONS
<DropdownMenuLabel className="px-2 mb-2 text-xs font-bold text-gray-500 truncate !block">
ORGANIZATIONS
</DropdownMenuLabel>
) : null}
{organizations.map(org => (

View file

@ -33,7 +33,6 @@ const TransferOrganizationOwnership_Members = graphql(`
id
cleanId
name
type
members {
nodes {
isOwner

View file

@ -2,7 +2,6 @@ fragment OrganizationFields on Organization {
id
cleanId
name
type
plan
me {
...MemberFields
@ -19,7 +18,6 @@ fragment OrganizationEssentials on Organization {
id
cleanId
name
type
}
fragment ProjectFields on Project {

View file

@ -8,7 +8,7 @@ export default defineConfig({
'@hive/service-common': 'packages/services/service-common/src/index.ts',
'@graphql-hive/core': 'packages/libraries/core/src/index.ts',
},
exclude: [...defaultExclude, 'integration-tests'],
exclude: [...defaultExclude, 'integration-tests', 'packages/migrations/test'],
setupFiles: ['./serializer.ts'],
},
});