mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
feat(core,schemas): add CRUD for consent organization resource scopes (#5804)
feat(core,schemas): add crud for user consent organization resource scopes
This commit is contained in:
parent
2e96eea60c
commit
5adf3dfad7
10 changed files with 176 additions and 26 deletions
|
@ -10,6 +10,11 @@ export const permissionTabs = Object.freeze({
|
|||
title: 'application_details.permissions.api_resource',
|
||||
key: ApplicationUserConsentScopeType.ResourceScopes,
|
||||
},
|
||||
[ApplicationUserConsentScopeType.OrganizationResourceScopes]: {
|
||||
// TODO @xiaoyijun: update the title
|
||||
title: 'application_details.permissions.api_resource',
|
||||
key: ApplicationUserConsentScopeType.OrganizationResourceScopes,
|
||||
},
|
||||
[ApplicationUserConsentScopeType.OrganizationScopes]: {
|
||||
title: 'application_details.permissions.organization',
|
||||
key: ApplicationUserConsentScopeType.OrganizationScopes,
|
||||
|
|
|
@ -72,6 +72,8 @@ const useApplicationScopesAssignment = (applicationId: string) => {
|
|||
[ApplicationUserConsentScopeType.UserScopes]: userScopesAssignment,
|
||||
[ApplicationUserConsentScopeType.OrganizationScopes]: organizationScopesAssignment,
|
||||
[ApplicationUserConsentScopeType.ResourceScopes]: resourceScopesAssignment,
|
||||
// TODO @xiaoyijun: Replace with correct scopes
|
||||
[ApplicationUserConsentScopeType.OrganizationResourceScopes]: resourceScopesAssignment,
|
||||
}),
|
||||
[organizationScopesAssignment, resourceScopesAssignment, userScopesAssignment]
|
||||
);
|
||||
|
|
|
@ -32,6 +32,7 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
applications: {
|
||||
findApplicationById,
|
||||
userConsentOrganizationScopes,
|
||||
userConsentOrganizationResourceScopes,
|
||||
userConsentResourceScopes,
|
||||
userConsentUserScopes,
|
||||
},
|
||||
|
@ -76,16 +77,20 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
{
|
||||
organizationScopes = [],
|
||||
resourceScopes = [],
|
||||
organizationResourceScopes = [],
|
||||
}: {
|
||||
organizationScopes?: string[];
|
||||
resourceScopes?: string[];
|
||||
organizationResourceScopes?: string[];
|
||||
},
|
||||
tenantId: string
|
||||
) => {
|
||||
const [organizationScopesData, resourceScopesData] = await Promise.all([
|
||||
organizationScopesQuery.findByIds(organizationScopes),
|
||||
findScopesByIds(resourceScopes),
|
||||
]);
|
||||
const [organizationScopesData, resourceScopesData, organizationResourceScopesData] =
|
||||
await Promise.all([
|
||||
organizationScopesQuery.findByIds(organizationScopes),
|
||||
findScopesByIds(resourceScopes),
|
||||
findScopesByIds(organizationResourceScopes),
|
||||
]);
|
||||
|
||||
const invalidOrganizationScopes = organizationScopes.filter(
|
||||
(scope) => !organizationScopesData.some(({ id }) => id === scope)
|
||||
|
@ -95,22 +100,28 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
(scope) => !resourceScopesData.some(({ id }) => id === scope)
|
||||
);
|
||||
|
||||
const invalidOrganizationResourceScopes = organizationResourceScopes.filter(
|
||||
(scope) => !organizationResourceScopesData.some(({ id }) => id === scope)
|
||||
);
|
||||
|
||||
// Assert that all scopes exist, return the missing ones
|
||||
assertThat(
|
||||
invalidOrganizationScopes.length === 0 && invalidResourceScopes.length === 0,
|
||||
invalidOrganizationScopes.length === 0 &&
|
||||
invalidResourceScopes.length === 0 &&
|
||||
invalidOrganizationResourceScopes.length === 0,
|
||||
new RequestError(
|
||||
{
|
||||
code: 'application.user_consent_scopes_not_found',
|
||||
status: 422,
|
||||
},
|
||||
{ invalidOrganizationScopes, invalidResourceScopes }
|
||||
{ invalidOrganizationScopes, invalidResourceScopes, invalidOrganizationResourceScopes }
|
||||
)
|
||||
);
|
||||
|
||||
const managementApiResourceIndicator = getManagementApiResourceIndicator(tenantId);
|
||||
|
||||
const managementApiScopes = await findScopesByIdsAndResourceIndicator(
|
||||
resourceScopes,
|
||||
[...resourceScopes, ...organizationResourceScopes],
|
||||
managementApiResourceIndicator
|
||||
);
|
||||
|
||||
|
@ -129,10 +140,12 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
{
|
||||
organizationScopes,
|
||||
resourceScopes,
|
||||
organizationResourceScopes,
|
||||
userScopes,
|
||||
}: {
|
||||
organizationScopes?: string[];
|
||||
resourceScopes?: string[];
|
||||
organizationResourceScopes?: string[];
|
||||
userScopes?: string[];
|
||||
}
|
||||
) => {
|
||||
|
@ -148,6 +161,12 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (organizationResourceScopes) {
|
||||
await userConsentOrganizationResourceScopes.insert(
|
||||
...organizationResourceScopes.map<[string, string]>((scope) => [applicationId, scope])
|
||||
);
|
||||
}
|
||||
|
||||
if (userScopes) {
|
||||
await Promise.all(
|
||||
userScopes.map(async (userScope) =>
|
||||
|
@ -181,6 +200,21 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
);
|
||||
};
|
||||
|
||||
const getApplicationUserConsentOrganizationResourceScopes = async (applicationId: string) => {
|
||||
const [, scopes] = await userConsentOrganizationResourceScopes.getEntities(Scopes, {
|
||||
applicationId,
|
||||
});
|
||||
|
||||
const groupedScopes = groupResourceScopesByResourceId(scopes);
|
||||
|
||||
return Promise.all(
|
||||
groupedScopes.map(async ({ resourceId, scopes }) => ({
|
||||
resource: await findResourceById(resourceId),
|
||||
scopes,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const getApplicationUserConsentScopes = async (applicationId: string) =>
|
||||
userConsentUserScopes.findAllByApplicationId(applicationId);
|
||||
|
||||
|
@ -198,6 +232,10 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
await userConsentResourceScopes.delete({ applicationId, scopeId });
|
||||
break;
|
||||
}
|
||||
case ApplicationUserConsentScopeType.OrganizationResourceScopes: {
|
||||
await userConsentOrganizationResourceScopes.delete({ applicationId, scopeId });
|
||||
break;
|
||||
}
|
||||
case ApplicationUserConsentScopeType.UserScopes: {
|
||||
await userConsentUserScopes.deleteByApplicationIdAndScopeId(applicationId, scopeId);
|
||||
break;
|
||||
|
@ -250,6 +288,7 @@ export const createApplicationLibrary = (queries: Queries) => {
|
|||
assignApplicationUserConsentScopes,
|
||||
getApplicationUserConsentOrganizationScopes,
|
||||
getApplicationUserConsentResourceScopes,
|
||||
getApplicationUserConsentOrganizationResourceScopes,
|
||||
getApplicationUserConsentScopes,
|
||||
deleteApplicationUserConsentScopesByTypeAndScopeId,
|
||||
validateUserConsentOrganizationMembership,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { type UserScope } from '@logto/core-kit';
|
|||
import {
|
||||
ApplicationUserConsentOrganizationScopes,
|
||||
ApplicationUserConsentResourceScopes,
|
||||
ApplicationUserConsentOrganizationResourceScopes,
|
||||
ApplicationUserConsentUserScopes,
|
||||
Applications,
|
||||
OrganizationScopes,
|
||||
|
@ -32,6 +33,15 @@ export class ApplicationUserConsentResourceScopeQueries extends TwoRelationsQuer
|
|||
}
|
||||
}
|
||||
|
||||
export class ApplicationUserConsentOrganizationResourceScopeQueries extends TwoRelationsQueries<
|
||||
typeof Applications,
|
||||
typeof Scopes
|
||||
> {
|
||||
constructor(pool: CommonQueryMethods) {
|
||||
super(pool, ApplicationUserConsentOrganizationResourceScopes.table, Applications, Scopes);
|
||||
}
|
||||
}
|
||||
|
||||
export const createApplicationUserConsentUserScopeQueries = (pool: CommonQueryMethods) => {
|
||||
const insert = buildInsertIntoWithPool(pool)(ApplicationUserConsentUserScopes, {
|
||||
onConflict: { ignore: true },
|
||||
|
|
|
@ -15,6 +15,7 @@ import type { OmitAutoSetFields } from '#src/utils/sql.js';
|
|||
|
||||
import ApplicationUserConsentOrganizationsQuery from './application-user-consent-organizations.js';
|
||||
import {
|
||||
ApplicationUserConsentOrganizationResourceScopeQueries,
|
||||
ApplicationUserConsentOrganizationScopeQueries,
|
||||
ApplicationUserConsentResourceScopeQueries,
|
||||
createApplicationUserConsentUserScopeQueries,
|
||||
|
@ -253,6 +254,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
deleteApplicationById,
|
||||
userConsentOrganizationScopes: new ApplicationUserConsentOrganizationScopeQueries(pool),
|
||||
userConsentResourceScopes: new ApplicationUserConsentResourceScopeQueries(pool),
|
||||
userConsentOrganizationResourceScopes:
|
||||
new ApplicationUserConsentOrganizationResourceScopeQueries(pool),
|
||||
userConsentUserScopes: createApplicationUserConsentUserScopeQueries(pool),
|
||||
userConsentOrganizations: new ApplicationUserConsentOrganizationsQuery(pool),
|
||||
};
|
||||
|
|
|
@ -15,6 +15,9 @@
|
|||
"resourceScopes": {
|
||||
"description": "A list of resource scope id to assign to the application. Throws error if any given resource scope is not found."
|
||||
},
|
||||
"organizationResourceScopes": {
|
||||
"description": "A list of organization resource scope id to assign to the application. Throws error if any given resource scope is not found."
|
||||
},
|
||||
"userScopes": {
|
||||
"description": "A list of user scope enum value to assign to the application."
|
||||
}
|
||||
|
@ -51,6 +54,9 @@
|
|||
"resourceScopes": {
|
||||
"description": "A list of resource scope details grouped by resource id assigned to the application."
|
||||
},
|
||||
"organizationResourceScopes": {
|
||||
"description": "A list of organization resource scope details grouped by resource id assigned to the application."
|
||||
},
|
||||
"userScopes": {
|
||||
"description": "A list of user scope enum value assigned to the application."
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from '@logto/schemas';
|
||||
import { object, string, nativeEnum } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
|
||||
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||
|
@ -24,6 +25,7 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
|
|||
assignApplicationUserConsentScopes,
|
||||
getApplicationUserConsentOrganizationScopes,
|
||||
getApplicationUserConsentResourceScopes,
|
||||
getApplicationUserConsentOrganizationResourceScopes,
|
||||
getApplicationUserConsentScopes,
|
||||
deleteApplicationUserConsentScopesByTypeAndScopeId,
|
||||
},
|
||||
|
@ -40,6 +42,7 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
|
|||
body: object({
|
||||
organizationScopes: string().array().optional(),
|
||||
resourceScopes: string().array().optional(),
|
||||
organizationResourceScopes: string().array().optional(),
|
||||
userScopes: nativeEnum(UserScope).array().optional(),
|
||||
}),
|
||||
status: [201, 404, 422],
|
||||
|
@ -50,11 +53,15 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
|
|||
body,
|
||||
} = ctx.guard;
|
||||
|
||||
// TODO @wangsijie: Remove this when feature is enabled in production
|
||||
const { organizationResourceScopes, ...rest } = body;
|
||||
const theBody = EnvSet.values.isDevFeaturesEnabled ? body : rest;
|
||||
|
||||
await validateThirdPartyApplicationById(applicationId);
|
||||
|
||||
await validateApplicationUserConsentScopes(body, tenantId);
|
||||
await validateApplicationUserConsentScopes(theBody, tenantId);
|
||||
|
||||
await assignApplicationUserConsentScopes(applicationId, body);
|
||||
await assignApplicationUserConsentScopes(applicationId, theBody);
|
||||
|
||||
ctx.status = 201;
|
||||
|
||||
|
@ -68,7 +75,9 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
|
|||
params: object({
|
||||
applicationId: string(),
|
||||
}),
|
||||
response: applicationUserConsentScopesResponseGuard,
|
||||
response: EnvSet.values.isDevFeaturesEnabled
|
||||
? applicationUserConsentScopesResponseGuard
|
||||
: applicationUserConsentScopesResponseGuard.omit({ organizationResourceScopes: true }),
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
|
@ -77,17 +86,26 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
|
|||
await findApplicationById(applicationId);
|
||||
|
||||
// Note: The following queries will return full data schema, we rely on the response guard to filter out the fields we don't need.
|
||||
const [organizationScopes, resourceScopes, userScopes] = await Promise.all([
|
||||
getApplicationUserConsentOrganizationScopes(applicationId),
|
||||
getApplicationUserConsentResourceScopes(applicationId),
|
||||
getApplicationUserConsentScopes(applicationId),
|
||||
]);
|
||||
const [organizationScopes, resourceScopes, organizationResourceScopes, userScopes] =
|
||||
await Promise.all([
|
||||
getApplicationUserConsentOrganizationScopes(applicationId),
|
||||
getApplicationUserConsentResourceScopes(applicationId),
|
||||
getApplicationUserConsentOrganizationResourceScopes(applicationId),
|
||||
getApplicationUserConsentScopes(applicationId),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
organizationScopes,
|
||||
resourceScopes,
|
||||
userScopes,
|
||||
};
|
||||
ctx.body = EnvSet.values.isDevFeaturesEnabled
|
||||
? {
|
||||
organizationScopes,
|
||||
resourceScopes,
|
||||
organizationResourceScopes,
|
||||
userScopes,
|
||||
}
|
||||
: {
|
||||
organizationScopes,
|
||||
resourceScopes,
|
||||
userScopes,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ export const assignUserConsentScopes = async (
|
|||
payload: {
|
||||
organizationScopes?: string[];
|
||||
resourceScopes?: string[];
|
||||
organizationResourceScopes?: string[];
|
||||
userScopes?: UserScope[];
|
||||
}
|
||||
) => authedAdminApi.post(`applications/${applicationId}/user-consent-scopes`, { json: payload });
|
||||
|
|
|
@ -16,6 +16,7 @@ describe('assign user consent scopes to application', () => {
|
|||
const applicationIds = new Map<string, string>();
|
||||
const organizationScopes = new Map<string, string>();
|
||||
const resourceScopes = new Map<string, string>();
|
||||
const organizationResourceScopes = new Map<string, string>();
|
||||
const resourceIds = new Set<string>();
|
||||
|
||||
const organizationScopeApi = new OrganizationScopeApi();
|
||||
|
@ -52,6 +53,12 @@ describe('assign user consent scopes to application', () => {
|
|||
|
||||
resourceScopes.set('resourceScope1', resourceScope1.id);
|
||||
resourceScopes.set('resourceScope2', resourceScope2.id);
|
||||
|
||||
const resourceScope3 = await createScope(resource.id);
|
||||
const resourceScope4 = await createScope(resource.id);
|
||||
|
||||
organizationResourceScopes.set('resourceScope1', resourceScope3.id);
|
||||
organizationResourceScopes.set('resourceScope2', resourceScope4.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
@ -75,6 +82,7 @@ describe('assign user consent scopes to application', () => {
|
|||
assignUserConsentScopes(applicationIds.get('firstPartyApp')!, {
|
||||
organizationScopes: Array.from(organizationScopes.values()),
|
||||
resourceScopes: Array.from(resourceScopes.values()),
|
||||
organizationResourceScopes: Array.from(organizationResourceScopes.values()),
|
||||
}),
|
||||
{
|
||||
code: 'application.third_party_application_only',
|
||||
|
@ -107,11 +115,24 @@ describe('assign user consent scopes to application', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should throw error when trying to assign a non-existing organization resource scope', async () => {
|
||||
await expectRejects(
|
||||
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
|
||||
organizationResourceScopes: ['non-existing-resource-scope'],
|
||||
}),
|
||||
{
|
||||
code: 'application.user_consent_scopes_not_found',
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should assign scopes to third-party application successfully', async () => {
|
||||
await expect(
|
||||
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
|
||||
organizationScopes: Array.from(organizationScopes.values()),
|
||||
resourceScopes: Array.from(resourceScopes.values()),
|
||||
organizationResourceScopes: Array.from(organizationResourceScopes.values()),
|
||||
userScopes: [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles],
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
@ -122,6 +143,7 @@ describe('assign user consent scopes to application', () => {
|
|||
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
|
||||
organizationScopes: [organizationScopes.get('organizationScope1')!],
|
||||
resourceScopes: [resourceScopes.get('resourceScope1')!],
|
||||
organizationResourceScopes: [organizationResourceScopes.get('resourceScope1')!],
|
||||
userScopes: [UserScope.Profile],
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
@ -154,6 +176,18 @@ describe('assign user consent scopes to application', () => {
|
|||
).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(result.organizationResourceScopes.length).toBe(1);
|
||||
expect(result.organizationResourceScopes[0]!.resource.id).toBe(Array.from(resourceIds)[0]);
|
||||
expect(result.organizationResourceScopes[0]!.scopes.length).toBe(
|
||||
organizationResourceScopes.size
|
||||
);
|
||||
|
||||
for (const resourceScopeId of organizationResourceScopes.values()) {
|
||||
expect(
|
||||
result.organizationResourceScopes[0]!.scopes.some(({ id }) => id === resourceScopeId)
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(result.userScopes.length).toBe(3);
|
||||
|
||||
for (const userScope of [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles]) {
|
||||
|
@ -170,6 +204,7 @@ describe('assign user consent scopes to application', () => {
|
|||
|
||||
expect(result.organizationScopes.length).toBe(0);
|
||||
expect(result.resourceScopes.length).toBe(0);
|
||||
expect(result.organizationResourceScopes.length).toBe(0);
|
||||
expect(result.userScopes.length).toBe(0);
|
||||
|
||||
await deleteApplication(newApp.id);
|
||||
|
@ -214,6 +249,18 @@ describe('assign user consent scopes to application', () => {
|
|||
}
|
||||
);
|
||||
|
||||
await expectRejects(
|
||||
deleteUserConsentScopes(
|
||||
applicationIds.get('thirdPartyApp')!,
|
||||
ApplicationUserConsentScopeType.OrganizationResourceScopes,
|
||||
'non-existing-resource-scope'
|
||||
),
|
||||
{
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
|
||||
await expectRejects(
|
||||
deleteUserConsentScopes(
|
||||
applicationIds.get('thirdPartyApp')!,
|
||||
|
@ -232,6 +279,7 @@ describe('assign user consent scopes to application', () => {
|
|||
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
|
||||
organizationScopes: Array.from(organizationScopes.values()),
|
||||
resourceScopes: Array.from(resourceScopes.values()),
|
||||
organizationResourceScopes: Array.from(organizationResourceScopes.values()),
|
||||
userScopes: [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles],
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
@ -252,6 +300,14 @@ describe('assign user consent scopes to application', () => {
|
|||
)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
deleteUserConsentScopes(
|
||||
applicationIds.get('thirdPartyApp')!,
|
||||
ApplicationUserConsentScopeType.OrganizationResourceScopes,
|
||||
organizationResourceScopes.get('resourceScope1')!
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
deleteUserConsentScopes(
|
||||
applicationIds.get('thirdPartyApp')!,
|
||||
|
@ -274,6 +330,12 @@ describe('assign user consent scopes to application', () => {
|
|||
)
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
result.organizationResourceScopes[0]!.scopes.find(
|
||||
({ id }) => id === organizationResourceScopes.get('resourceScope1')!
|
||||
)
|
||||
).toBeUndefined();
|
||||
|
||||
expect(result.userScopes.includes(UserScope.OrganizationRoles)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -40,22 +40,26 @@ export const applicationPatchGuard = applicationCreateGuard.partial().omit({
|
|||
isThirdParty: true,
|
||||
});
|
||||
|
||||
const resourceScopesGuard = z.array(
|
||||
z.object({
|
||||
resource: Resources.guard.pick({ id: true, name: true, indicator: true }),
|
||||
scopes: z.array(Scopes.guard.pick({ id: true, name: true, description: true })),
|
||||
})
|
||||
);
|
||||
|
||||
export const applicationUserConsentScopesResponseGuard = z.object({
|
||||
organizationScopes: z.array(
|
||||
OrganizationScopes.guard.pick({ id: true, name: true, description: true })
|
||||
),
|
||||
resourceScopes: z.array(
|
||||
z.object({
|
||||
resource: Resources.guard.pick({ id: true, name: true, indicator: true }),
|
||||
scopes: z.array(Scopes.guard.pick({ id: true, name: true, description: true })),
|
||||
})
|
||||
),
|
||||
resourceScopes: resourceScopesGuard,
|
||||
organizationResourceScopes: resourceScopesGuard,
|
||||
userScopes: z.array(z.nativeEnum(UserScope)),
|
||||
});
|
||||
|
||||
export enum ApplicationUserConsentScopeType {
|
||||
OrganizationScopes = 'organization-scopes',
|
||||
ResourceScopes = 'resource-scopes',
|
||||
OrganizationResourceScopes = 'organization-resource-scopes',
|
||||
UserScopes = 'user-scopes',
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue