mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
fix(core): fix new linked social email conflict error (#4623)
This commit is contained in:
parent
46d0d4c0b9
commit
04ec524e67
3 changed files with 195 additions and 168 deletions
151
packages/core/src/routes/interaction/actions/helpers.ts
Normal file
151
packages/core/src/routes/interaction/actions/helpers.ts
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
import { defaults, parseAffiliateData } from '@logto/affiliate';
|
||||||
|
import { consoleLog } from '@logto/cli/lib/utils.js';
|
||||||
|
import { type CreateUser, type User, adminTenantId } from '@logto/schemas';
|
||||||
|
import { type OmitAutoSetFields } from '@logto/shared';
|
||||||
|
import { conditional, trySafe } from '@silverhand/essentials';
|
||||||
|
import { type IRouterContext } from 'koa-router';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||||
|
import { type ConnectorLibrary } from '#src/libraries/connector.js';
|
||||||
|
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||||
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Identifier,
|
||||||
|
type SocialIdentifier,
|
||||||
|
type VerifiedSignInInteractionResult,
|
||||||
|
type VerifiedRegisterInteractionResult,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { categorizeIdentifiers } from '../utils/interaction.js';
|
||||||
|
|
||||||
|
const filterSocialIdentifiers = (identifiers: Identifier[]): SocialIdentifier[] =>
|
||||||
|
identifiers.filter((identifier): identifier is SocialIdentifier => identifier.key === 'social');
|
||||||
|
|
||||||
|
/* Sync avatar and name from the latest social identity for existing users */
|
||||||
|
const getSocialSyncProfile = async (
|
||||||
|
{ getLogtoConnectorById }: ConnectorLibrary,
|
||||||
|
authIdentifiers: Identifier[]
|
||||||
|
) => {
|
||||||
|
const socialIdentifier = filterSocialIdentifiers(authIdentifiers).at(-1);
|
||||||
|
|
||||||
|
if (!socialIdentifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
userInfo: { name, avatar },
|
||||||
|
connectorId,
|
||||||
|
} = socialIdentifier;
|
||||||
|
|
||||||
|
const {
|
||||||
|
dbEntry: { syncProfile },
|
||||||
|
} = await getLogtoConnectorById(connectorId);
|
||||||
|
|
||||||
|
return conditional(
|
||||||
|
syncProfile && {
|
||||||
|
...conditional(name && { name }),
|
||||||
|
...conditional(avatar && { avatar }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Parse the user profile from the new linked Social identity */
|
||||||
|
const parseNewSocialProfile = async (
|
||||||
|
{ users: { hasUserWithEmail, hasUserWithPhone } }: Queries,
|
||||||
|
{ getLogtoConnectorById }: ConnectorLibrary,
|
||||||
|
socialIdentifier: SocialIdentifier,
|
||||||
|
user?: User
|
||||||
|
) => {
|
||||||
|
const { connectorId, userInfo } = socialIdentifier;
|
||||||
|
const { name, avatar, id, email, phone } = userInfo;
|
||||||
|
|
||||||
|
const {
|
||||||
|
metadata: { target },
|
||||||
|
dbEntry: { syncProfile },
|
||||||
|
} = await getLogtoConnectorById(connectorId);
|
||||||
|
|
||||||
|
// Sync the social identity, merge the new social identity with the existing one
|
||||||
|
const identities = { ...user?.identities, [target]: { userId: id, details: userInfo } };
|
||||||
|
|
||||||
|
// Parse the profile for new user (register)
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
identities,
|
||||||
|
...conditional(name && { name }),
|
||||||
|
...conditional(avatar && { avatar }),
|
||||||
|
// Sync the email only if the email is not used by other users
|
||||||
|
...conditional(email && !(await hasUserWithEmail(email)) && { primaryEmail: email }),
|
||||||
|
// Sync the phone only if the phone is not used by other users
|
||||||
|
...conditional(phone && !(await hasUserWithPhone(phone)) && { primaryPhone: phone }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the user name and avatar to the existing user if the connector has syncProfile enabled (sign-in)
|
||||||
|
return {
|
||||||
|
identities,
|
||||||
|
...conditional(
|
||||||
|
syncProfile && {
|
||||||
|
...conditional(name && { name }),
|
||||||
|
...conditional(avatar && { avatar }),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Parse the user profile from the interaction result. */
|
||||||
|
export const parseUserProfile = async (
|
||||||
|
{ connectors, queries }: TenantContext,
|
||||||
|
{ profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult,
|
||||||
|
user?: User
|
||||||
|
): Promise<Omit<OmitAutoSetFields<CreateUser>, 'id'>> => {
|
||||||
|
const { authIdentifiers, profileIdentifiers } = categorizeIdentifiers(identifiers ?? [], profile);
|
||||||
|
const { phone, username, email, connectorId, password } = profile ?? {};
|
||||||
|
|
||||||
|
// Parse the new social profiles
|
||||||
|
const socialProfileIdentifier = connectorId
|
||||||
|
? profileIdentifiers.find(
|
||||||
|
(identifier): identifier is SocialIdentifier =>
|
||||||
|
identifier.key === 'social' && identifier.connectorId === connectorId
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const newSocialProfile =
|
||||||
|
socialProfileIdentifier &&
|
||||||
|
(await parseNewSocialProfile(queries, connectors, socialProfileIdentifier, user));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...newSocialProfile,
|
||||||
|
...conditional(password && (await encryptUserPassword(password))),
|
||||||
|
...conditional(phone && { primaryPhone: phone }),
|
||||||
|
...conditional(email && { primaryEmail: email }),
|
||||||
|
...conditional(username && { username }),
|
||||||
|
...conditional(user && (await getSocialSyncProfile(connectors, authIdentifiers))),
|
||||||
|
lastSignInAt: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Post affiliate data to the cloud service. */
|
||||||
|
export const postAffiliateLogs = async (
|
||||||
|
ctx: IRouterContext,
|
||||||
|
cloudConnection: CloudConnectionLibrary,
|
||||||
|
userId: string,
|
||||||
|
tenantId: string
|
||||||
|
) => {
|
||||||
|
if (!EnvSet.values.isCloud || tenantId !== adminTenantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affiliateData = trySafe(() =>
|
||||||
|
parseAffiliateData(JSON.parse(decodeURIComponent(ctx.cookies.get(defaults.cookieName) ?? '')))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (affiliateData) {
|
||||||
|
const client = await cloudConnection.getClient();
|
||||||
|
await client.post('/api/affiliate-logs', {
|
||||||
|
body: { userId, ...affiliateData },
|
||||||
|
});
|
||||||
|
consoleLog.info('Affiliate logs posted', userId);
|
||||||
|
}
|
||||||
|
};
|
|
@ -51,8 +51,11 @@ const userQueries = {
|
||||||
}),
|
}),
|
||||||
updateUserById: jest.fn(),
|
updateUserById: jest.fn(),
|
||||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||||
|
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||||
|
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||||
};
|
};
|
||||||
const { hasActiveUsers, updateUserById } = userQueries;
|
|
||||||
|
const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries;
|
||||||
|
|
||||||
const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() };
|
const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() };
|
||||||
const { generateUserId, insertUser } = userLibraries;
|
const { generateUserId, insertUser } = userLibraries;
|
||||||
|
@ -175,6 +178,38 @@ describe('submit action', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('register new social user should not sync email and phone if already exists', async () => {
|
||||||
|
hasUserWithEmail.mockResolvedValueOnce(true);
|
||||||
|
hasUserWithPhone.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
const interaction: VerifiedRegisterInteractionResult = {
|
||||||
|
event: InteractionEvent.Register,
|
||||||
|
profile: { connectorId: 'logto', username: 'username' },
|
||||||
|
identifiers,
|
||||||
|
};
|
||||||
|
|
||||||
|
await submitInteraction(interaction, ctx, tenant);
|
||||||
|
|
||||||
|
expect(generateUserId).toBeCalled();
|
||||||
|
expect(hasActiveUsers).not.toBeCalled();
|
||||||
|
expect(encryptUserPassword).not.toBeCalled();
|
||||||
|
expect(getLogtoConnectorById).toBeCalledWith('logto');
|
||||||
|
|
||||||
|
expect(insertUser).toBeCalledWith(
|
||||||
|
{
|
||||||
|
id: 'uid',
|
||||||
|
username: 'username',
|
||||||
|
identities: {
|
||||||
|
logto: { userId: userInfo.id, details: userInfo },
|
||||||
|
},
|
||||||
|
name: userInfo.name,
|
||||||
|
avatar: userInfo.avatar,
|
||||||
|
lastSignInAt: now,
|
||||||
|
},
|
||||||
|
['user']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('register with bindMfa', async () => {
|
it('register with bindMfa', async () => {
|
||||||
const interaction: VerifiedRegisterInteractionResult = {
|
const interaction: VerifiedRegisterInteractionResult = {
|
||||||
event: InteractionEvent.Register,
|
event: InteractionEvent.Register,
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { defaults, parseAffiliateData } from '@logto/affiliate';
|
|
||||||
import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event';
|
import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event';
|
||||||
import { appInsights } from '@logto/app-insights/node';
|
import { appInsights } from '@logto/app-insights/node';
|
||||||
import type { User, Profile, CreateUser } from '@logto/schemas';
|
import type { User } from '@logto/schemas';
|
||||||
import {
|
import {
|
||||||
AdminTenantRole,
|
AdminTenantRole,
|
||||||
SignInMode,
|
SignInMode,
|
||||||
|
@ -11,13 +10,10 @@ import {
|
||||||
InteractionEvent,
|
InteractionEvent,
|
||||||
adminConsoleApplicationId,
|
adminConsoleApplicationId,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
|
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
|
||||||
import { type IRouterContext } from 'koa-router';
|
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
|
||||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
|
||||||
import { assignInteractionResults } from '#src/libraries/session.js';
|
import { assignInteractionResults } from '#src/libraries/session.js';
|
||||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||||
import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js';
|
import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
|
@ -28,145 +24,13 @@ import { getTenantId } from '#src/utils/tenant.js';
|
||||||
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
|
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
|
||||||
import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js';
|
import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js';
|
||||||
import type {
|
import type {
|
||||||
Identifier,
|
|
||||||
VerifiedInteractionResult,
|
VerifiedInteractionResult,
|
||||||
SocialIdentifier,
|
|
||||||
VerifiedSignInInteractionResult,
|
VerifiedSignInInteractionResult,
|
||||||
VerifiedRegisterInteractionResult,
|
VerifiedRegisterInteractionResult,
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import { clearInteractionStorage, categorizeIdentifiers } from '../utils/interaction.js';
|
import { clearInteractionStorage } from '../utils/interaction.js';
|
||||||
|
|
||||||
const filterSocialIdentifiers = (identifiers: Identifier[]): SocialIdentifier[] =>
|
import { postAffiliateLogs, parseUserProfile } from './helpers.js';
|
||||||
identifiers.filter((identifier): identifier is SocialIdentifier => identifier.key === 'social');
|
|
||||||
|
|
||||||
const getNewSocialProfile = async (
|
|
||||||
{ getLogtoConnectorById }: ConnectorLibrary,
|
|
||||||
{
|
|
||||||
user,
|
|
||||||
connectorId,
|
|
||||||
identifiers,
|
|
||||||
}: {
|
|
||||||
user?: User;
|
|
||||||
connectorId: string;
|
|
||||||
identifiers: SocialIdentifier[];
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
// TODO: @simeng refactor me. This step should be verified by the previous profile verification cycle Already.
|
|
||||||
const socialIdentifier = identifiers.find((identifier) => identifier.connectorId === connectorId);
|
|
||||||
|
|
||||||
if (!socialIdentifier) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
metadata: { target },
|
|
||||||
dbEntry: { syncProfile },
|
|
||||||
} = await getLogtoConnectorById(connectorId);
|
|
||||||
|
|
||||||
const { userInfo } = socialIdentifier;
|
|
||||||
const { name, avatar, id, email, phone } = userInfo;
|
|
||||||
|
|
||||||
const identities = { ...user?.identities, [target]: { userId: id, details: userInfo } };
|
|
||||||
|
|
||||||
// Sync the name, avatar, email and phone for new user
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
identities,
|
|
||||||
...conditional(name && { name }),
|
|
||||||
...conditional(avatar && { avatar }),
|
|
||||||
...conditional(email && { primaryEmail: email }),
|
|
||||||
...conditional(phone && { primaryPhone: phone }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync the user name and avatar if the connector has syncProfile enabled
|
|
||||||
return {
|
|
||||||
identities,
|
|
||||||
...conditional(
|
|
||||||
syncProfile && {
|
|
||||||
...conditional(name && { name }),
|
|
||||||
...conditional(avatar && { avatar }),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLatestUserProfileFromSocial = async (
|
|
||||||
{ getLogtoConnectorById }: ConnectorLibrary,
|
|
||||||
authIdentifiers: Identifier[]
|
|
||||||
) => {
|
|
||||||
const socialIdentifier = filterSocialIdentifiers(authIdentifiers).at(-1);
|
|
||||||
|
|
||||||
if (!socialIdentifier) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
userInfo: { name, avatar },
|
|
||||||
connectorId,
|
|
||||||
} = socialIdentifier;
|
|
||||||
|
|
||||||
const {
|
|
||||||
dbEntry: { syncProfile },
|
|
||||||
} = await getLogtoConnectorById(connectorId);
|
|
||||||
|
|
||||||
return conditional(
|
|
||||||
syncProfile && {
|
|
||||||
...conditional(name && { name }),
|
|
||||||
...conditional(avatar && { avatar }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseNewUserProfile = async (
|
|
||||||
connectorLibrary: ConnectorLibrary,
|
|
||||||
profile: Profile,
|
|
||||||
profileIdentifiers: Identifier[],
|
|
||||||
user?: User
|
|
||||||
) => {
|
|
||||||
const { phone, username, email, connectorId, password } = profile;
|
|
||||||
|
|
||||||
const [passwordProfile, socialProfile] = await Promise.all([
|
|
||||||
conditional(password && (await encryptUserPassword(password))),
|
|
||||||
conditional(
|
|
||||||
connectorId &&
|
|
||||||
(await getNewSocialProfile(connectorLibrary, {
|
|
||||||
connectorId,
|
|
||||||
identifiers: filterSocialIdentifiers(profileIdentifiers),
|
|
||||||
user,
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...socialProfile, // SocialProfile should be applied first
|
|
||||||
...passwordProfile,
|
|
||||||
...conditional(phone && { primaryPhone: phone }),
|
|
||||||
...conditional(username && { username }),
|
|
||||||
...conditional(email && { primaryEmail: email }),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseUserProfile = async (
|
|
||||||
connectorLibrary: ConnectorLibrary,
|
|
||||||
{ profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult,
|
|
||||||
user?: User
|
|
||||||
): Promise<Omit<OmitAutoSetFields<CreateUser>, 'id'>> => {
|
|
||||||
const { authIdentifiers, profileIdentifiers } = categorizeIdentifiers(identifiers ?? [], profile);
|
|
||||||
|
|
||||||
const newUserProfile =
|
|
||||||
profile && (await parseNewUserProfile(connectorLibrary, profile, profileIdentifiers, user));
|
|
||||||
|
|
||||||
// Sync from the latest social identity profile for existing users
|
|
||||||
const syncedSocialUserProfile =
|
|
||||||
user && (await getLatestUserProfileFromSocial(connectorLibrary, authIdentifiers));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...syncedSocialUserProfile,
|
|
||||||
...newUserProfile,
|
|
||||||
lastSignInAt: Date.now(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseBindMfa = ({
|
const parseBindMfa = ({
|
||||||
bindMfa,
|
bindMfa,
|
||||||
|
@ -196,36 +60,13 @@ const getInitialUserRoles = (
|
||||||
isCreatingFirstAdminUser && isCloud && getManagementApiAdminName(adminTenantId)
|
isCreatingFirstAdminUser && isCloud && getManagementApiAdminName(adminTenantId)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Post affiliate data to the cloud service. */
|
|
||||||
const postAffiliateLogs = async (
|
|
||||||
ctx: IRouterContext,
|
|
||||||
cloudConnection: CloudConnectionLibrary,
|
|
||||||
userId: string,
|
|
||||||
tenantId: string
|
|
||||||
) => {
|
|
||||||
if (!EnvSet.values.isCloud || tenantId !== adminTenantId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const affiliateData = trySafe(() =>
|
|
||||||
parseAffiliateData(JSON.parse(decodeURIComponent(ctx.cookies.get(defaults.cookieName) ?? '')))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (affiliateData) {
|
|
||||||
const client = await cloudConnection.getClient();
|
|
||||||
await client.post('/api/affiliate-logs', {
|
|
||||||
body: { userId, ...affiliateData },
|
|
||||||
});
|
|
||||||
consoleLog.info('Affiliate logs posted', userId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function submitInteraction(
|
export default async function submitInteraction(
|
||||||
interaction: VerifiedInteractionResult,
|
interaction: VerifiedInteractionResult,
|
||||||
ctx: WithLogContext & WithInteractionDetailsContext & WithInteractionHooksContext,
|
ctx: WithLogContext & WithInteractionDetailsContext & WithInteractionHooksContext,
|
||||||
{ provider, libraries, connectors, queries, cloudConnection, id: tenantId }: TenantContext,
|
tenantContext: TenantContext,
|
||||||
log?: LogEntry
|
log?: LogEntry
|
||||||
) {
|
) {
|
||||||
|
const { provider, libraries, queries, cloudConnection, id: tenantId } = tenantContext;
|
||||||
const { hasActiveUsers, findUserById, updateUserById } = queries.users;
|
const { hasActiveUsers, findUserById, updateUserById } = queries.users;
|
||||||
const { updateDefaultSignInExperience } = queries.signInExperiences;
|
const { updateDefaultSignInExperience } = queries.signInExperiences;
|
||||||
|
|
||||||
|
@ -236,7 +77,7 @@ export default async function submitInteraction(
|
||||||
|
|
||||||
if (event === InteractionEvent.Register) {
|
if (event === InteractionEvent.Register) {
|
||||||
const id = await generateUserId();
|
const id = await generateUserId();
|
||||||
const userProfile = await parseUserProfile(connectors, interaction);
|
const userProfile = await parseUserProfile(tenantContext, interaction);
|
||||||
const mfaVerification = parseBindMfa(interaction);
|
const mfaVerification = parseBindMfa(interaction);
|
||||||
|
|
||||||
const { client_id } = ctx.interactionDetails.params;
|
const { client_id } = ctx.interactionDetails.params;
|
||||||
|
@ -283,7 +124,7 @@ export default async function submitInteraction(
|
||||||
|
|
||||||
if (event === InteractionEvent.SignIn) {
|
if (event === InteractionEvent.SignIn) {
|
||||||
const user = await findUserById(accountId);
|
const user = await findUserById(accountId);
|
||||||
const updateUserProfile = await parseUserProfile(connectors, interaction, user);
|
const updateUserProfile = await parseUserProfile(tenantContext, interaction, user);
|
||||||
const mfaVerification = parseBindMfa(interaction);
|
const mfaVerification = parseBindMfa(interaction);
|
||||||
|
|
||||||
await updateUserById(accountId, {
|
await updateUserById(accountId, {
|
||||||
|
|
Loading…
Add table
Reference in a new issue