feat(app): move policy page into settings (#7107)

This commit is contained in:
Iha Shin (신의하) 2025-10-16 18:32:56 +09:00 committed by GitHub
parent d9067e86b4
commit 252507bdc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 736 additions and 789 deletions

View file

@ -45,7 +45,6 @@ export enum Page {
Overview = 'overview',
Members = 'members',
Settings = 'settings',
Policy = 'policy',
Support = 'support',
Subscription = 'subscription',
}
@ -54,11 +53,9 @@ const OrganizationLayout_OrganizationFragment = graphql(`
fragment OrganizationLayout_OrganizationFragment on Organization {
id
slug
viewerCanModifySchemaPolicy
viewerCanCreateProject
viewerCanManageSupportTickets
viewerCanDescribeBilling
viewerCanAccessSettings
viewerCanSeeMembers
...ProPlanBilling_OrganizationFragment
...RateLimitWarn_OrganizationFragment
@ -156,24 +153,14 @@ export function OrganizationLayout({
</Link>
</TabsTrigger>
)}
<TabsTrigger variant="menu" value={Page.Policy} asChild>
<TabsTrigger variant="menu" value={Page.Settings} asChild>
<Link
to="/$organizationSlug/view/policy"
to="/$organizationSlug/view/settings"
params={{ organizationSlug: currentOrganization.slug }}
>
Policy
Settings
</Link>
</TabsTrigger>
{currentOrganization.viewerCanAccessSettings && (
<TabsTrigger variant="menu" value={Page.Settings} asChild>
<Link
to="/$organizationSlug/view/settings"
params={{ organizationSlug: currentOrganization.slug }}
>
Settings
</Link>
</TabsTrigger>
)}
{currentOrganization.viewerCanManageSupportTickets && (
<TabsTrigger variant="menu" value={Page.Support} asChild>
<Link

View file

@ -29,7 +29,6 @@ import { ProjectSelector } from './project-selector';
export enum Page {
Targets = 'targets',
Alerts = 'alerts',
Policy = 'policy',
Settings = 'settings',
}
@ -52,7 +51,6 @@ const ProjectLayoutQuery = graphql(`
viewerCanModifySchemaPolicy
viewerCanCreateTarget
viewerCanModifyAlerts
viewerCanModifySettings
}
}
}
@ -142,30 +140,17 @@ export function ProjectLayout({
</Link>
</TabsTrigger>
)}
<TabsTrigger variant="menu" value={Page.Policy} asChild>
<TabsTrigger variant="menu" value={Page.Settings} asChild>
<Link
to="/$organizationSlug/$projectSlug/view/policy"
to="/$organizationSlug/$projectSlug/view/settings"
params={{
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
}}
>
Policy
Settings
</Link>
</TabsTrigger>
{currentProject.viewerCanModifySettings && (
<TabsTrigger variant="menu" value={Page.Settings} asChild>
<Link
to="/$organizationSlug/$projectSlug/view/settings"
params={{
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
}}
>
Settings
</Link>
</TabsTrigger>
)}
</TabsList>
</Tabs>
) : (

View file

@ -1,7 +1,8 @@
import { useState } from 'react';
import { useMutation, useQuery } from 'urql';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardDescription } from '@/components/ui/card';
import { CheckIcon } from '@/components/ui/icon';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { Spinner } from '@/components/ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FragmentType, graphql, useFragment } from '@/gql';
@ -90,14 +91,12 @@ export const CompositionSettings = (props: {
};
return (
<Card>
<CardHeader>
<CardTitle>
<a id="composition">Schema Composition</a>
</CardTitle>
<CardDescription>Configure how your schemas are composed.</CardDescription>
</CardHeader>
<CardContent>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle={<a id="composition">Schema Composition</a>}
description={<CardDescription>Configure how your schemas are composed.</CardDescription>}
/>
<div>
{projectQuery.fetching ? (
<Spinner />
) : (
@ -149,7 +148,7 @@ export const CompositionSettings = (props: {
</TabsContent>
</Tabs>
)}
</CardContent>
</Card>
</div>
</SubPageLayout>
);
};

View file

@ -1,178 +0,0 @@
import { ReactElement } from 'react';
import { useMutation, useQuery } from 'urql';
import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { PolicySettings } from '@/components/policy/policy-settings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { DocsLink } from '@/components/ui/docs-note';
import { Meta } from '@/components/ui/meta';
import { Subtitle, Title } from '@/components/ui/page';
import { QueryError } from '@/components/ui/query-error';
import { useToast } from '@/components/ui/use-toast';
import { graphql } from '@/gql';
const OrganizationPolicyPageQuery = graphql(`
query OrganizationPolicyPageQuery($organizationSlug: String!) {
organization: organizationBySlug(organizationSlug: $organizationSlug) {
id
schemaPolicy {
id
updatedAt
...PolicySettings_SchemaPolicyFragment
}
viewerCanModifySchemaPolicy
}
}
`);
const UpdateSchemaPolicyForOrganization = graphql(`
mutation UpdateSchemaPolicyForOrganization(
$selector: OrganizationSelectorInput!
$policy: SchemaPolicyInput!
$allowOverrides: Boolean!
) {
updateSchemaPolicyForOrganization(
selector: $selector
policy: $policy
allowOverrides: $allowOverrides
) {
error {
message
}
ok {
organization {
id
schemaPolicy {
id
updatedAt
allowOverrides
...PolicySettings_SchemaPolicyFragment
}
}
}
}
}
`);
function PolicyPageContent(props: { organizationSlug: string }) {
const [query] = useQuery({
query: OrganizationPolicyPageQuery,
variables: {
organizationSlug: props.organizationSlug,
},
});
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForOrganization);
const { toast } = useToast();
const currentOrganization = query.data?.organization;
if (query.error) {
return <QueryError organizationSlug={props.organizationSlug} error={query.error} />;
}
return (
<OrganizationLayout
page={Page.Policy}
organizationSlug={props.organizationSlug}
className="flex flex-col gap-y-10"
>
<div>
<div className="py-6">
<Title>Organization Schema Policy</Title>
<Subtitle>
Schema Policies enable developers to define additional semantic checks on the GraphQL
schema.
</Subtitle>
</div>
{currentOrganization ? (
<Card>
<CardHeader>
<CardTitle>Rules</CardTitle>
<CardDescription>
At the organizational level, policies can be defined to affect all projects and
targets.
<br />
At the project level, policies can be overridden or extended.
<br />
<DocsLink className="text-muted-foreground" href="/features/schema-policy">
Learn more
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<PolicySettings
saving={mutation.fetching}
error={
mutation.error?.message ||
mutation.data?.updateSchemaPolicyForOrganization.error?.message
}
onSave={
currentOrganization.viewerCanModifySchemaPolicy
? async (newPolicy, allowOverrides) => {
await mutate({
selector: {
organizationSlug: props.organizationSlug,
},
policy: newPolicy,
allowOverrides,
})
.then(result => {
if (
result.data?.updateSchemaPolicyForOrganization.error ||
result.error
) {
toast({
variant: 'destructive',
title: 'Error',
description:
result.data?.updateSchemaPolicyForOrganization.error?.message ||
result.error?.message,
});
} else {
toast({
variant: 'default',
title: 'Success',
description: 'Policy updated successfully',
});
}
})
.catch();
}
: null
}
currentState={currentOrganization.schemaPolicy}
>
{form => (
<div className="flex items-center pl-1 pt-2">
<Checkbox
id="allowOverrides"
checked={form.values.allowOverrides}
value="allowOverrides"
onCheckedChange={newValue => form.setFieldValue('allowOverrides', newValue)}
disabled={!currentOrganization.viewerCanModifySchemaPolicy}
/>
<label
htmlFor="allowOverrides"
className="ml-2 inline-block text-sm text-gray-300"
>
Allow projects to override or disable rules
</label>
</div>
)}
</PolicySettings>
</CardContent>
</Card>
) : null}
</div>
</OrganizationLayout>
);
}
export function OrganizationPolicyPage(props: { organizationSlug: string }): ReactElement {
return (
<>
<Meta title="Organization Schema Policy" />
<PolicyPageContent organizationSlug={props.organizationSlug} />
</>
);
}

View file

@ -6,15 +6,10 @@ import { z } from 'zod';
import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { AccessTokensSubPage } from '@/components/organization/settings/access-tokens/access-tokens-sub-page';
import { OIDCIntegrationSection } from '@/components/organization/settings/oidc-integration-section';
import { PolicySettings } from '@/components/policy/policy-settings';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CardDescription } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
@ -28,7 +23,13 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from '@/component
import { GitHubIcon, SlackIcon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Meta } from '@/components/ui/meta';
import { NavLayout, PageLayout, PageLayoutContent } from '@/components/ui/page-content-layout';
import {
NavLayout,
PageLayout,
PageLayoutContent,
SubPageLayout,
SubPageLayoutHeader,
} from '@/components/ui/page-content-layout';
import { QueryError } from '@/components/ui/query-error';
import { ResourceDetails } from '@/components/ui/resource-details';
import { useToast } from '@/components/ui/use-toast';
@ -201,7 +202,7 @@ const SlugFormSchema = z.object({
type SlugFormValues = z.infer<typeof SlugFormSchema>;
const SettingsPageRenderer = (props: {
const OrganizationSettingsContent = (props: {
organization: FragmentType<typeof SettingsPageRenderer_OrganizationFragment>;
organizationSlug: string;
}) => {
@ -263,224 +264,366 @@ const SettingsPageRenderer = (props: {
);
return (
<div className="flex flex-col gap-y-4">
<div className="space-y-12">
<ResourceDetails id={organization.id} />
{organization.viewerCanModifySlug && (
<Form {...slugForm}>
<form onSubmit={slugForm.handleSubmit(onSlugFormSubmit)}>
<Card>
<CardHeader>
<CardTitle>Organization Slug</CardTitle>
<CardDescription>
This is your organization's URL namespace on GraphQL Hive. Changing it{' '}
<span className="font-bold">will</span> invalidate any existing links to your
organization.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#change-slug-of-organization"
>
You can read more about it in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<FormField
control={slugForm.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center">
<div className="border-input text-muted-foreground h-10 rounded-md rounded-r-none border-y border-l bg-gray-900 px-3 py-2 text-sm">
{env.appBaseUrl.replace(/https?:\/\//i, '')}/
</div>
<Input placeholder="slug" className="w-48 rounded-l-none" {...field} />
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Organization Slug"
description={
<>
<CardDescription>
This is your organization's URL namespace on GraphQL Hive. Changing it{' '}
<span className="font-bold">will</span> invalidate any existing links to your
organization.
</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#change-slug-of-organization"
>
You can read more about it in the documentation
</DocsLink>
</CardDescription>
</>
}
/>
<FormField
control={slugForm.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center">
<div className="border-input text-muted-foreground h-10 rounded-md rounded-r-none border-y border-l bg-gray-900 px-3 py-2 text-sm">
{env.appBaseUrl.replace(/https?:\/\//i, '')}/
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter className="flex justify-between">
<Button disabled={slugForm.formState.isSubmitting} className="px-10" type="submit">
Save
</Button>
</CardFooter>
</Card>
<Input placeholder="slug" className="w-48 rounded-l-none" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button disabled={slugForm.formState.isSubmitting} className="px-10" type="submit">
Save
</Button>
</SubPageLayout>
</form>
</Form>
)}
{organization.viewerCanManageOIDCIntegration && (
<Card>
<CardHeader>
<CardTitle>Single Sign On Provider</CardTitle>
<CardDescription>
Link your Hive organization to a single-sign-on provider such as Okta or Microsoft
Entra ID via OpenID Connect.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/sso-oidc-provider"
>
Instructions for connecting your provider.
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4 text-gray-500">
<OIDCIntegrationSection organization={organization} />
</div>
</CardContent>
</Card>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Single Sign On Provider"
description={
<>
<CardDescription>
Link your Hive organization to a single-sign-on provider such as Okta or Microsoft
Entra ID via OpenID Connect.
</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/sso-oidc-provider"
>
Instructions for connecting your provider.
</DocsLink>
</CardDescription>
</>
}
/>
<div className="text-gray-500">
<OIDCIntegrationSection organization={organization} />
</div>
</SubPageLayout>
)}
{organization.viewerCanModifySlackIntegration && (
<Card>
<CardHeader>
<CardTitle>Slack Integration</CardTitle>
<CardDescription>
Link your Hive organization with Slack for schema change notifications.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#slack"
>
Learn more.
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<SlackIntegrationSection organization={organization} />
</CardContent>
</Card>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Slack Integration"
description={
<>
<CardDescription>
Link your Hive organization with Slack for schema change notifications.
</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#slack"
>
Learn more.
</DocsLink>
</CardDescription>
</>
}
/>
<SlackIntegrationSection organization={organization} />
</SubPageLayout>
)}
{organization.viewerCanModifyGitHubIntegration && (
<Card>
<CardHeader>
<CardTitle>GitHub Integration</CardTitle>
<CardDescription>
Link your Hive organization with GitHub.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#github"
>
Learn more.
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<GitHubIntegrationSection organization={organization} />
</CardContent>
</Card>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="GitHub Integration"
description={
<>
<CardDescription>Link your Hive organization with GitHub.</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#github"
>
Learn more.
</DocsLink>
</CardDescription>
</>
}
/>
<GitHubIntegrationSection organization={organization} />
</SubPageLayout>
)}
{organization.viewerCanTransferOwnership && (
<Card>
<CardHeader>
<CardTitle>Transfer Ownership</CardTitle>
<CardDescription>
<strong>You are currently the owner of the organization.</strong> You can transfer the
organization to another member of the organization, or to an external user.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#transfer-ownership"
>
Learn more about the process
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<Button variant="destructive" onClick={toggleTransferModalOpen} className="px-5">
Transfer Ownership
</Button>
<TransferOrganizationOwnershipModal
isOpen={isTransferModalOpen}
toggleModalOpen={toggleTransferModalOpen}
organization={organization}
/>
</div>
</div>
</CardContent>
</Card>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Transfer Ownership"
description={
<>
<CardDescription>
<strong>You are currently the owner of the organization.</strong> You can transfer
the organization to another member of the organization, or to an external user.
</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#transfer-ownership"
>
Learn more about the process
</DocsLink>
</CardDescription>
</>
}
/>
<Button variant="destructive" onClick={toggleTransferModalOpen} className="px-5">
Transfer Ownership
</Button>
<TransferOrganizationOwnershipModal
isOpen={isTransferModalOpen}
toggleModalOpen={toggleTransferModalOpen}
organization={organization}
/>
</SubPageLayout>
)}
{organization.viewerCanDelete && (
<Card>
<CardHeader>
<CardTitle>Delete Organization</CardTitle>
<CardDescription>
Deleting an organization will delete all the projects, targets, schemas and data
associated with it.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#delete-an-organization"
>
<strong>This action is not reversible!</strong> You can find more information about
this process in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive" onClick={toggleDeleteModalOpen} className="px-5">
Delete Organization
</Button>
<DeleteOrganizationModal
organizationSlug={props.organizationSlug}
isOpen={isDeleteModalOpen}
toggleModalOpen={toggleDeleteModalOpen}
/>
</CardContent>
</Card>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Delete Organization"
description={
<>
<CardDescription>
Deleting an organization will delete all the projects, targets, schemas and data
associated with it.
</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#delete-an-organization"
>
<span>
<strong>This action is not reversible!</strong> You can find more information
about this process in the documentation
</span>
</DocsLink>
</CardDescription>
</>
}
/>
<Button variant="destructive" onClick={toggleDeleteModalOpen} className="px-5">
Delete Organization
</Button>
<DeleteOrganizationModal
organizationSlug={props.organizationSlug}
isOpen={isDeleteModalOpen}
toggleModalOpen={toggleDeleteModalOpen}
/>
</SubPageLayout>
)}
{organization.viewerCanExportAuditLogs && (
<Card>
<CardHeader>
<CardTitle>Audit Logs</CardTitle>
<CardDescription>
View a history of changes made to the organization settings.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<Button variant="default" onClick={toggleAuditLogsModalOpen} className="px-5">
Export Audit Logs
</Button>
<AuditLogsOrganizationModal
organizationSlug={organization.slug}
isOpen={isAuditLogsModalOpen}
toggleModalOpen={toggleAuditLogsModalOpen}
/>
</div>
</div>
</CardContent>
</Card>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Audit Logs"
description={
<>
<CardDescription>
View a history of changes made to the organization settings.
</CardDescription>
<CardDescription>
<DocsLink className="text-muted-foreground text-sm" href="/management/audit-logs">
Learn more.
</DocsLink>
</CardDescription>
</>
}
/>
<Button variant="default" onClick={toggleAuditLogsModalOpen} className="px-5">
Export Audit Logs
</Button>
<AuditLogsOrganizationModal
organizationSlug={organization.slug}
isOpen={isAuditLogsModalOpen}
toggleModalOpen={toggleAuditLogsModalOpen}
/>
</SubPageLayout>
)}
</div>
);
};
const OrganizationPolicySettings_OrganizationFragment = graphql(`
fragment OrganizationPolicySettings_OrganizationFragment on Organization {
id
slug
schemaPolicy {
id
updatedAt
...PolicySettings_SchemaPolicyFragment
}
viewerCanModifySchemaPolicy
}
`);
const UpdateSchemaPolicyForOrganization = graphql(`
mutation UpdateSchemaPolicyForOrganization(
$selector: OrganizationSelectorInput!
$policy: SchemaPolicyInput!
$allowOverrides: Boolean!
) {
updateSchemaPolicyForOrganization(
selector: $selector
policy: $policy
allowOverrides: $allowOverrides
) {
error {
message
}
ok {
organization {
id
schemaPolicy {
id
updatedAt
allowOverrides
...PolicySettings_SchemaPolicyFragment
}
}
}
}
}
`);
function OrganizationPolicySettings(props: {
organization: FragmentType<typeof OrganizationPolicySettings_OrganizationFragment>;
}) {
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForOrganization);
const { toast } = useToast();
const currentOrganization = useFragment(
OrganizationPolicySettings_OrganizationFragment,
props.organization,
);
return (
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Rules"
description={
<CardDescription>
At the organizational level, policies can be defined to affect all projects and targets.
<br />
At the project level, policies can be overridden or extended.
<br />
<DocsLink className="text-muted-foreground" href="/features/schema-policy">
Learn more
</DocsLink>
</CardDescription>
}
/>
<PolicySettings
saving={mutation.fetching}
error={
mutation.error?.message || mutation.data?.updateSchemaPolicyForOrganization.error?.message
}
onSave={
currentOrganization.viewerCanModifySchemaPolicy
? async (newPolicy, allowOverrides) => {
await mutate({
selector: {
organizationSlug: currentOrganization.slug,
},
policy: newPolicy,
allowOverrides,
})
.then(result => {
if (result.data?.updateSchemaPolicyForOrganization.error || result.error) {
toast({
variant: 'destructive',
title: 'Error',
description:
result.data?.updateSchemaPolicyForOrganization.error?.message ||
result.error?.message,
});
} else {
toast({
variant: 'default',
title: 'Success',
description: 'Policy updated successfully',
});
}
})
.catch();
}
: null
}
currentState={currentOrganization.schemaPolicy}
>
{form => (
<div className="flex items-center pl-1 pt-2">
<Checkbox
id="allowOverrides"
checked={form.values.allowOverrides}
value="allowOverrides"
onCheckedChange={newValue => form.setFieldValue('allowOverrides', newValue)}
disabled={!currentOrganization.viewerCanModifySchemaPolicy}
/>
<label htmlFor="allowOverrides" className="ml-2 inline-block text-sm text-gray-300">
Allow projects to override or disable rules
</label>
</div>
)}
</PolicySettings>
</SubPageLayout>
);
}
const OrganizationSettingsPageQuery = graphql(`
query OrganizationSettingsPageQuery($organizationSlug: String!) {
organization: organizationBySlug(organizationSlug: $organizationSlug) {
...SettingsPageRenderer_OrganizationFragment
...OrganizationPolicySettings_OrganizationFragment
viewerCanAccessSettings
viewerCanManageAccessTokens
}
}
`);
export const OrganizationSettingsPageEnum = z.enum(['general', 'access-tokens']);
export const OrganizationSettingsPageEnum = z.enum(['general', 'policy', 'access-tokens']);
export type OrganizationSettingsSubPage = z.TypeOf<typeof OrganizationSettingsPageEnum>;
function SettingsPageContent(props: {
@ -510,6 +653,11 @@ function SettingsPageContent(props: {
});
}
pages.push({
key: 'policy',
title: 'Policy',
});
if (currentOrganization?.viewerCanManageAccessTokens) {
pages.push({
key: 'access-tokens',
@ -577,15 +725,20 @@ function SettingsPageContent(props: {
})}
</NavLayout>
<PageLayoutContent>
{resolvedPage.key === 'general' ? (
<SettingsPageRenderer
organizationSlug={props.organizationSlug}
organization={currentOrganization}
/>
) : null}
{resolvedPage.key === 'access-tokens' ? (
<AccessTokensSubPage organizationSlug={props.organizationSlug} />
) : null}
<div className="space-y-12">
{resolvedPage.key === 'general' ? (
<OrganizationSettingsContent
organizationSlug={props.organizationSlug}
organization={currentOrganization}
/>
) : null}
{resolvedPage.key === 'policy' ? (
<OrganizationPolicySettings organization={currentOrganization} />
) : null}
{resolvedPage.key === 'access-tokens' ? (
<AccessTokensSubPage organizationSlug={props.organizationSlug} />
) : null}
</div>
</PageLayoutContent>
</PageLayout>
</OrganizationLayout>

View file

@ -1,181 +0,0 @@
import { useMutation, useQuery } from 'urql';
import { Page, ProjectLayout } from '@/components/layouts/project';
import { PolicySettings } from '@/components/policy/policy-settings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DocsLink } from '@/components/ui/docs-note';
import { Meta } from '@/components/ui/meta';
import { Subtitle, Title } from '@/components/ui/page';
import { QueryError } from '@/components/ui/query-error';
import { useToast } from '@/components/ui/use-toast';
import { graphql } from '@/gql';
const ProjectPolicyPageQuery = graphql(`
query ProjectPolicyPageQuery($organizationSlug: String!, $projectSlug: String!) {
organization: organizationBySlug(organizationSlug: $organizationSlug) {
id
project: projectBySlug(projectSlug: $projectSlug) {
id
schemaPolicy {
id
updatedAt
...PolicySettings_SchemaPolicyFragment
}
parentSchemaPolicy {
id
updatedAt
allowOverrides
rules {
rule {
id
}
}
}
viewerCanModifySchemaPolicy
}
}
}
`);
const UpdateSchemaPolicyForProject = graphql(`
mutation UpdateSchemaPolicyForProject(
$selector: ProjectSelectorInput!
$policy: SchemaPolicyInput!
) {
updateSchemaPolicyForProject(selector: $selector, policy: $policy) {
error {
message
}
ok {
project {
id
schemaPolicy {
id
updatedAt
...PolicySettings_SchemaPolicyFragment
}
}
}
}
}
`);
function ProjectPolicyContent(props: { organizationSlug: string; projectSlug: string }) {
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForProject);
const [query] = useQuery({
query: ProjectPolicyPageQuery,
variables: {
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
requestPolicy: 'cache-and-network',
});
const { toast } = useToast();
const currentOrganization = query.data?.organization;
const currentProject = currentOrganization?.project;
if (query.error) {
return (
<QueryError
organizationSlug={props.organizationSlug}
error={query.error}
showLogoutButton={false}
/>
);
}
return (
<div>
<div className="py-6">
<Title>Project Schema Policy</Title>
<Subtitle>
Schema Policies enable developers to define additional semantic checks on the GraphQL
schema.
</Subtitle>
</div>
{currentProject && currentOrganization ? (
<Card>
<CardHeader>
<CardTitle>Rules</CardTitle>
<CardDescription>
At the project level, policies can be defined to affect all targets, and override
policy configuration defined at the organization level.
<br />
<DocsLink href="/features/schema-policy" className="text-muted-foreground text-sm">
Learn more
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
{currentProject.parentSchemaPolicy === null ||
currentProject.parentSchemaPolicy?.allowOverrides ? (
<PolicySettings
saving={mutation.fetching}
rulesInParent={currentProject.parentSchemaPolicy?.rules.map(r => r.rule.id)}
error={
mutation.error?.message ||
mutation.data?.updateSchemaPolicyForProject.error?.message
}
onSave={
currentProject?.viewerCanModifySchemaPolicy
? async newPolicy => {
await mutate({
selector: {
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
policy: newPolicy,
}).then(result => {
if (result.error || result.data?.updateSchemaPolicyForProject.error) {
toast({
variant: 'destructive',
title: 'Error',
description:
result.error?.message ||
result.data?.updateSchemaPolicyForProject.error?.message,
});
} else {
toast({
variant: 'default',
title: 'Success',
description: 'Policy updated successfully',
});
}
});
}
: null
}
currentState={currentProject.schemaPolicy}
/>
) : (
<div className="pl-1 text-sm font-bold text-gray-400">
<p className="mr-4 inline-block text-orange-500">!</p>
Organization settings does not allow projects to override policy. Please consult
your organization administrator.
</div>
)}
</CardContent>
</Card>
) : null}
</div>
);
}
export function ProjectPolicyPage(props: { organizationSlug: string; projectSlug: string }) {
return (
<>
<Meta title="Project Schema Policy" />
<ProjectLayout
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
page={Page.Policy}
className="flex flex-col gap-y-10"
>
<ProjectPolicyContent
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
/>
</ProjectLayout>
</>
);
}

View file

@ -1,19 +1,13 @@
import { ReactElement, useCallback } from 'react';
import { ReactElement, useCallback, useMemo } from 'react';
import { ArrowBigDownDashIcon, CheckIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useMutation, useQuery } from 'urql';
import { z } from 'zod';
import { Page, ProjectLayout } from '@/components/layouts/project';
import { PolicySettings } from '@/components/policy/policy-settings';
import { CompositionSettings } from '@/components/project/settings/composition';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CardDescription } from '@/components/ui/card';
import {
Dialog,
DialogContent,
@ -27,16 +21,23 @@ import { Form, FormControl, FormField, FormItem, FormMessage } from '@/component
import { HiveLogo } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Meta } from '@/components/ui/meta';
import { Subtitle, Title } from '@/components/ui/page';
import {
NavLayout,
PageLayout,
PageLayoutContent,
SubPageLayout,
SubPageLayoutHeader,
} from '@/components/ui/page-content-layout';
import { QueryError } from '@/components/ui/query-error';
import { ResourceDetails } from '@/components/ui/resource-details';
import { useToast } from '@/components/ui/use-toast';
import { env } from '@/env/frontend';
import { graphql, useFragment } from '@/gql';
import { FragmentType, graphql, useFragment } from '@/gql';
import { ProjectType } from '@/gql/graphql';
import { useRedirect } from '@/lib/access/common';
import { getDocsUrl } from '@/lib/docs-url';
import { useNotifications, useToggle } from '@/lib/hooks';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from '@tanstack/react-router';
@ -91,82 +92,76 @@ function GitHubIntegration(props: {
}
return (
<Card>
<CardHeader>
<CardTitle>Use project's name in GitHub Check</CardTitle>
<CardDescription>
Prevents GitHub Check name collisions when running{' '}
<a href={docksLink}>
<span className="mx-1 text-orange-700 hover:underline hover:underline-offset-4">
$ hive schema:check --github
</span>
</a>
for more than one project.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground mb-4 flex flex-row items-center justify-between gap-x-4 rounded-sm text-sm">
<div className="space-y-2">
<div>
<div className="mb-4">Here's how it will look like in your CI pipeline.</div>
<div className="flex items-center gap-x-2 pl-1">
<CheckIcon className="size-4 text-emerald-500" />
<div className="flex size-6 items-center justify-center rounded-sm bg-white">
<HiveLogo className="size-4/5" />
</div>
<div className="font-semibold text-[#adbac7]">
{props.organizationSlug} &gt; schema:check &gt; staging
</div>
<div className="text-gray-500"> No changes</div>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Use project's name in GitHub Check"
description={
<CardDescription>
Prevents GitHub Check name collisions when running{' '}
<a href={docksLink}>
<span className="mx-1 text-orange-700 hover:underline hover:underline-offset-4">
$ hive schema:check --github
</span>
</a>
for more than one project.
</CardDescription>
}
/>
<div>
<div className="text-muted-foreground text-sm">
<div>Here's how it will look like in your CI pipeline.</div>
<div className="my-8 flex w-fit flex-col gap-y-1">
<div className="flex items-center gap-x-2 pl-1">
<CheckIcon className="size-4 text-emerald-500" />
<div className="flex size-6 items-center justify-center rounded-sm bg-white">
<HiveLogo className="size-4/5" />
</div>
</div>
<div>
<ArrowBigDownDashIcon className="size-6" />
</div>
<div>
<div className="flex items-center gap-x-2 pl-1">
<CheckIcon className="size-4 text-emerald-500" />
<div className="flex size-6 items-center justify-center rounded-sm bg-white">
<HiveLogo className="size-4/5" />
</div>
<div className="font-semibold text-[#adbac7]">
{props.organizationSlug} &gt; schema:check &gt; {props.projectSlug} &gt; staging
</div>
<div className="text-gray-500"> No changes</div>
<div className="font-semibold text-[#adbac7]">
{props.organizationSlug} &gt; schema:check &gt; staging
</div>
<div className="text-gray-500"> No changes</div>
</div>
<ArrowBigDownDashIcon className="size-6 self-center" />
<div className="flex items-center gap-x-2 pl-1">
<CheckIcon className="size-4 text-emerald-500" />
<div className="flex size-6 items-center justify-center rounded-sm bg-white">
<HiveLogo className="size-4/5" />
</div>
<div className="font-semibold text-[#adbac7]">
{props.organizationSlug} &gt; schema:check &gt; {props.projectSlug} &gt; staging
</div>
<div className="text-gray-500"> No changes</div>
</div>
</div>
<div className="pr-6">
<Button
disabled={ghCheckMutation.fetching}
onClick={() => {
void ghCheckMutate({
input: {
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
}).then(
result => {
if (result.error) {
notify('Failed to enable', 'error');
} else {
notify('Migration completed', 'success');
}
},
_ => {
notify('Failed to enable', 'error');
},
);
}}
>
I want to migrate
</Button>
</div>
</div>
</CardContent>
</Card>
<Button
disabled={ghCheckMutation.fetching}
onClick={() => {
void ghCheckMutate({
input: {
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
}).then(
result => {
if (result.error) {
notify('Failed to enable', 'error');
} else {
notify('Migration completed', 'success');
}
},
_ => {
notify('Failed to enable', 'error');
},
);
}}
>
I want to migrate
</Button>
</div>
</SubPageLayout>
);
}
@ -259,22 +254,25 @@ function ProjectSettingsPage_SlugForm(props: { organizationSlug: string; project
return (
<Form {...slugForm}>
<form onSubmit={slugForm.handleSubmit(onSlugFormSubmit)}>
<Card>
<CardHeader>
<CardTitle>Project Slug</CardTitle>
<CardDescription>
This is your project's URL namespace on Hive. Changing it{' '}
<span className="font-bold">will</span> invalidate any existing links to your project.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#change-slug-of-a-project"
>
You can read more about it in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Project Slug"
description={
<CardDescription>
This is your project's URL namespace on Hive. Changing it{' '}
<span className="font-bold">will</span> invalidate any existing links to your
project.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#change-slug-of-a-project"
>
You can read more about it in the documentation
</DocsLink>
</CardDescription>
}
/>
<div>
<FormField
control={slugForm.control}
name="slug"
@ -292,18 +290,176 @@ function ProjectSettingsPage_SlugForm(props: { organizationSlug: string; project
</FormItem>
)}
/>
</CardContent>
<CardFooter className="flex justify-between">
<Button disabled={slugForm.formState.isSubmitting} className="px-10" type="submit">
Save
</Button>
</CardFooter>
</Card>
</div>
</SubPageLayout>
</form>
</Form>
);
}
function ProjectDelete(props: { organizationSlug: string; projectSlug: string }) {
const [isModalOpen, toggleModalOpen] = useToggle();
return (
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Delete Project"
description={
<>
<CardDescription>
Deleting an project will delete all the targets, schemas and data associated with it.
</CardDescription>
<CardDescription>
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#delete-a-project"
>
<strong>This action is not reversible!</strong> You can find more information about
this process in the documentation
</DocsLink>
</CardDescription>
</>
}
/>
<Button variant="destructive" onClick={toggleModalOpen}>
Delete Project
</Button>
<DeleteProjectModal
projectSlug={props.projectSlug}
organizationSlug={props.organizationSlug}
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
/>
</SubPageLayout>
);
}
const ProjectPolicySettings_ProjectFragment = graphql(`
fragment ProjectPolicySettings_ProjectFragment on Project {
id
slug
schemaPolicy {
id
updatedAt
...PolicySettings_SchemaPolicyFragment
}
parentSchemaPolicy {
id
updatedAt
allowOverrides
rules {
rule {
id
}
}
}
viewerCanModifySchemaPolicy
}
`);
const UpdateSchemaPolicyForProject = graphql(`
mutation UpdateSchemaPolicyForProject(
$selector: ProjectSelectorInput!
$policy: SchemaPolicyInput!
) {
updateSchemaPolicyForProject(selector: $selector, policy: $policy) {
error {
message
}
ok {
project {
id
schemaPolicy {
id
updatedAt
...PolicySettings_SchemaPolicyFragment
}
}
}
}
}
`);
function ProjectPolicySettings(props: {
organizationSlug: string;
project: FragmentType<typeof ProjectPolicySettings_ProjectFragment>;
}) {
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForProject);
const { toast } = useToast();
const currentProject = useFragment(ProjectPolicySettings_ProjectFragment, props.project);
return (
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="Rules"
description={
<>
<CardDescription>
At the project level, policies can be defined to affect all targets, and override
policy configuration defined at the organization level.
</CardDescription>
<CardDescription>
<DocsLink href="/features/schema-policy" className="text-muted-foreground text-sm">
Learn more
</DocsLink>
</CardDescription>
</>
}
/>
{currentProject.parentSchemaPolicy === null ||
currentProject.parentSchemaPolicy?.allowOverrides ? (
<PolicySettings
saving={mutation.fetching}
rulesInParent={currentProject.parentSchemaPolicy?.rules.map(r => r.rule.id)}
error={
mutation.error?.message || mutation.data?.updateSchemaPolicyForProject.error?.message
}
onSave={
currentProject?.viewerCanModifySchemaPolicy
? async newPolicy => {
await mutate({
selector: {
organizationSlug: props.organizationSlug,
projectSlug: currentProject.slug,
},
policy: newPolicy,
}).then(result => {
if (result.error || result.data?.updateSchemaPolicyForProject.error) {
toast({
variant: 'destructive',
title: 'Error',
description:
result.error?.message ||
result.data?.updateSchemaPolicyForProject.error?.message,
});
} else {
toast({
variant: 'default',
title: 'Success',
description: 'Policy updated successfully',
});
}
});
}
: null
}
currentState={currentProject.schemaPolicy}
/>
) : (
<div className="pl-1 text-sm font-bold text-gray-400">
<p className="mr-4 inline-block text-orange-500">!</p>
Organization settings does not allow projects to override policy. Please consult your
organization administrator.
</div>
)}
</SubPageLayout>
);
}
const ProjectSettingsPage_OrganizationFragment = graphql(`
fragment ProjectSettingsPage_OrganizationFragment on Organization {
id
@ -321,6 +477,7 @@ const ProjectSettingsPage_ProjectFragment = graphql(`
viewerCanDelete
viewerCanModifySettings
...CompositionSettings_ProjectFragment
...ProjectPolicySettings_ProjectFragment
}
`);
@ -336,8 +493,12 @@ const ProjectSettingsPageQuery = graphql(`
}
`);
function ProjectSettingsContent(props: { organizationSlug: string; projectSlug: string }) {
const [isModalOpen, toggleModalOpen] = useToggle();
function ProjectSettingsContent(props: {
organizationSlug: string;
projectSlug: string;
page?: ProjectSettingsSubPage;
}) {
const router = useRouter();
const [query] = useQuery({
query: ProjectSettingsPageQuery,
variables: {
@ -369,7 +530,30 @@ function ProjectSettingsContent(props: { organizationSlug: string; projectSlug:
entity: project,
});
if (project?.viewerCanModifySettings === false) {
const subPages = useMemo(() => {
const pages: Array<{
key: ProjectSettingsSubPage;
title: string;
}> = [];
if (project?.viewerCanModifySettings) {
pages.push({
key: 'general',
title: 'General',
});
}
pages.push({
key: 'policy',
title: 'Policy',
});
return pages;
}, [project]);
const resolvedPage = props.page ? subPages.find(page => page.key === props.page) : subPages.at(0);
if (!resolvedPage || !organization || !project) {
return null;
}
@ -384,66 +568,74 @@ function ProjectSettingsContent(props: { organizationSlug: string; projectSlug:
}
return (
<div>
<div className="py-6">
<Title>Settings</Title>
<Subtitle>Manage your project settings</Subtitle>
</div>
<div className="flex flex-col gap-y-4">
{project && organization ? (
<>
<ResourceDetails id={project.id} />
<ProjectSettingsPage_SlugForm
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
/>
{query.data?.isGitHubIntegrationFeatureEnabled &&
!project.isProjectNameInGitHubCheckEnabled ? (
<GitHubIntegration organizationSlug={organization.slug} projectSlug={project.slug} />
) : null}
{project.type === ProjectType.Federation ? (
<CompositionSettings project={project} organization={organization} />
) : null}
{project.viewerCanDelete && (
<Card>
<CardHeader>
<CardTitle>Delete Project</CardTitle>
<CardDescription>
Deleting an project will delete all the targets, schemas and data associated
with it.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#delete-a-project"
>
<strong>This action is not reversible!</strong> You can find more information
about this process in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardFooter>
<Button variant="destructive" onClick={toggleModalOpen}>
Delete Project
</Button>
</CardFooter>
</Card>
<PageLayout>
<NavLayout>
{subPages.map(subPage => (
<Button
key={subPage.key}
variant="ghost"
onClick={() => {
void router.navigate({
search: {
page: subPage.key,
},
});
}}
className={cn(
resolvedPage.key === subPage.key
? 'bg-muted hover:bg-muted'
: 'hover:bg-transparent hover:underline',
'w-full justify-start text-left',
)}
<DeleteProjectModal
projectSlug={props.projectSlug}
organizationSlug={props.organizationSlug}
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
/>
</>
) : null}
</div>
</div>
>
{subPage.title}
</Button>
))}
</NavLayout>
<PageLayoutContent>
<div className="space-y-12">
{resolvedPage.key === 'general' ? (
<>
<ResourceDetails id={project.id} />
<ProjectSettingsPage_SlugForm
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
/>
{query.data?.isGitHubIntegrationFeatureEnabled &&
!project.isProjectNameInGitHubCheckEnabled ? (
<GitHubIntegration
organizationSlug={organization.slug}
projectSlug={project.slug}
/>
) : null}
{project.type === ProjectType.Federation ? (
<CompositionSettings project={project} organization={organization} />
) : null}
{project.viewerCanDelete ? (
<ProjectDelete projectSlug={project.slug} organizationSlug={organization.slug} />
) : null}
</>
) : null}
{resolvedPage.key === 'policy' ? (
<ProjectPolicySettings organizationSlug={organization.slug} project={project} />
) : null}
</div>
</PageLayoutContent>
</PageLayout>
);
}
export function ProjectSettingsPage(props: { organizationSlug: string; projectSlug: string }) {
export const ProjectSettingsPageEnum = z.enum(['general', 'policy']);
export type ProjectSettingsSubPage = z.TypeOf<typeof ProjectSettingsPageEnum>;
export function ProjectSettingsPage(props: {
organizationSlug: string;
projectSlug: string;
page?: ProjectSettingsSubPage;
}) {
return (
<>
<Meta title="Project settings" />
@ -456,6 +648,7 @@ export function ProjectSettingsPage(props: { organizationSlug: string; projectSl
<ProjectSettingsContent
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
page={props.page}
/>
</ProjectLayout>
</>

View file

@ -46,7 +46,6 @@ import { OrganizationIndexRouteSearch, OrganizationPage } from './pages/organiza
import { JoinOrganizationPage } from './pages/organization-join';
import { OrganizationMembersPage } from './pages/organization-members';
import { NewOrgPage } from './pages/organization-new';
import { OrganizationPolicyPage } from './pages/organization-policy';
import {
OrganizationSettingsPage,
OrganizationSettingsPageEnum,
@ -58,8 +57,7 @@ import { OrganizationSupportTicketPage } from './pages/organization-support-tick
import { OrganizationTransferPage } from './pages/organization-transfer';
import { ProjectIndexRouteSearch, ProjectPage } from './pages/project';
import { ProjectAlertsPage } from './pages/project-alerts';
import { ProjectPolicyPage } from './pages/project-policy';
import { ProjectSettingsPage } from './pages/project-settings';
import { ProjectSettingsPage, ProjectSettingsPageEnum } from './pages/project-settings';
import { TargetPage } from './pages/target';
import { TargetAppVersionPage } from './pages/target-app-version';
import { TargetAppsPage } from './pages/target-apps';
@ -433,15 +431,6 @@ const organizationSubscriptionManageRoute = createRoute({
},
});
const organizationPolicyRoute = createRoute({
getParentRoute: () => organizationRoute,
path: 'view/policy',
component: function OrganizationPolicyRoute() {
const { organizationSlug } = organizationPolicyRoute.useParams();
return <OrganizationPolicyPage organizationSlug={organizationSlug} />;
},
});
const OrganizationSettingRouteSearch = z.object({
page: OrganizationSettingsPageEnum.default('general').optional(),
});
@ -516,21 +505,27 @@ const projectIndexRoute = createRoute({
},
});
const ProjectSettingsRouteSearch = z.object({
page: ProjectSettingsPageEnum.default('general').optional(),
});
const projectSettingsRoute = createRoute({
getParentRoute: () => projectRoute,
path: 'view/settings',
validateSearch(search) {
return ProjectSettingsRouteSearch.parse(search);
},
component: function ProjectSettingsRoute() {
const { organizationSlug, projectSlug } = projectSettingsRoute.useParams();
return <ProjectSettingsPage organizationSlug={organizationSlug} projectSlug={projectSlug} />;
},
});
const { page } = projectSettingsRoute.useSearch();
const projectPolicyRoute = createRoute({
getParentRoute: () => projectRoute,
path: 'view/policy',
component: function ProjectPolicyRoute() {
const { organizationSlug, projectSlug } = projectPolicyRoute.useParams();
return <ProjectPolicyPage organizationSlug={organizationSlug} projectSlug={projectSlug} />;
return (
<ProjectSettingsPage
organizationSlug={organizationSlug}
projectSlug={projectSlug}
page={page}
/>
);
},
});
@ -950,15 +945,9 @@ const routeTree = root.addChildren([
organizationSubscriptionManageRoute,
organizationSubscriptionManageLegacyRoute,
organizationMembersRoute,
organizationPolicyRoute,
organizationSettingsRoute,
]),
projectRoute.addChildren([
projectIndexRoute,
projectSettingsRoute,
projectPolicyRoute,
projectAlertsRoute,
]),
projectRoute.addChildren([projectIndexRoute, projectSettingsRoute, projectAlertsRoute]),
targetRoute.addChildren([
targetIndexRoute,
targetSettingsRoute,