0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat: create tenant for new users (#3255)

This commit is contained in:
Gao Sun 2023-03-01 20:55:26 +08:00 committed by GitHub
parent 6246fc58ba
commit 44909140bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 116 additions and 53 deletions

View file

@ -19,11 +19,12 @@
"start": "NODE_ENV=production node ."
},
"dependencies": {
"@logto/cli": "workspace:*",
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "2.2.0",
"@withtyped/postgres": "^0.8.0",
"@withtyped/postgres": "^0.8.1",
"@withtyped/server": "^0.8.0",
"chalk": "^5.0.0",
"decamelize": "^6.0.0",

View file

@ -1,6 +1,11 @@
import { generateStandardId } from '@logto/core-kit';
import type { TenantInfo, TenantModel } from '@logto/schemas';
import {
generateOidcCookieKey,
generateOidcPrivateKey,
} from '@logto/cli/lib/commands/database/utils.js';
import { generateStandardId } from '@logto/core-kit';
import type { LogtoOidcConfigType, TenantInfo, TenantModel } from '@logto/schemas';
import {
LogtoOidcConfigKey,
LogtoConfigs,
SignInExperiences,
createDefaultAdminConsoleConfig,
@ -25,6 +30,13 @@ export const tenantInfoGuard: ZodType<TenantInfo> = z.object({
indicator: z.string(),
});
const oidcConfigBuilders: {
[key in LogtoOidcConfigKey]: () => Promise<LogtoOidcConfigType[key]>;
} = {
[LogtoOidcConfigKey.CookieKeys]: async () => [generateOidcCookieKey()],
[LogtoOidcConfigKey.PrivateKeys]: async () => [await generateOidcPrivateKey()],
};
export class TenantsLibrary {
constructor(public readonly queries: Queries) {}
@ -50,7 +62,7 @@ export class TenantsLibrary {
const tenants = createTenantsQueries(transaction);
const users = createUsersQueries(transaction);
// Start
/* --- Start --- */
await transaction.start();
// Init tenant
@ -70,14 +82,24 @@ export class TenantsLibrary {
// Create initial configs
await Promise.all([
...Object.entries(oidcConfigBuilders).map(async ([key, build]) =>
transaction.query(insertInto({ tenantId, key, value: await build() }, LogtoConfigs.table))
),
transaction.query(insertInto(createDefaultAdminConsoleConfig(tenantId), LogtoConfigs.table)),
transaction.query(
insertInto(createDefaultSignInExperience(tenantId), SignInExperiences.table)
),
]);
// End
// Update Redirect URI for Admin Console
await tenants.appendAdminConsoleRedirectUris(
...['http://localhost:3003', 'https://cloud.logto.dev'].map(
(endpoint) => new URL(`/${tenantModel.id}/callback`, endpoint)
)
);
await transaction.end();
/* --- End --- */
return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator };
}

View file

@ -3,13 +3,14 @@ import assert from 'node:assert';
import { generateStandardId } from '@logto/core-kit';
import type { AdminData, TenantModel } from '@logto/schemas';
import {
adminConsoleApplicationId,
adminTenantId,
getManagementApiResourceIndicator,
PredefinedScope,
CreateRolesScope,
} from '@logto/schemas';
import type { PostgreSql } from '@withtyped/postgres';
import { dangerousRaw, id, sql } from '@withtyped/postgres';
import { jsonb, dangerousRaw, id, sql } from '@withtyped/postgres';
import type { Queryable } from '@withtyped/server';
import { insertInto } from '#src/utils/query.js';
@ -72,5 +73,26 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
);
};
return { getManagementApiLikeIndicatorsForUser, insertTenant, createTenantRole, insertAdminData };
const appendAdminConsoleRedirectUris = async (...urls: URL[]) => {
const metadataKey = id('oidc_client_metadata');
await client.query(sql`
update applications
set ${metadataKey} = jsonb_set(
${metadataKey},
'{redirectUris}',
${metadataKey}->'redirectUris' || ${jsonb(urls.map(String))}
)
where id = ${adminConsoleApplicationId}
and tenant_id = ${adminTenantId}
`);
};
return {
getManagementApiLikeIndicatorsForUser,
insertTenant,
createTenantRole,
insertAdminData,
appendAdminConsoleRedirectUris,
};
};

View file

@ -1,6 +1,7 @@
import type { TenantInfo } from '@logto/schemas';
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { AppLoadingOffline } from '@/components/AppLoading/Offline';
import Button from '@/components/Button';
import DangerousRaw from '@/components/DangerousRaw';
@ -9,18 +10,27 @@ import * as styles from './index.module.scss';
type Props = {
data: TenantInfo[];
onAdd: (tenant: TenantInfo) => void;
};
const Tenants = ({ data }: Props) => {
const Tenants = ({ data, onAdd }: Props) => {
const api = useCloudApi();
const createTenant = useCallback(async () => {
onAdd(await api.post('api/tenants').json<TenantInfo>());
}, [api, onAdd]);
useEffect(() => {
if (data.length <= 1) {
if (data[0]) {
window.location.assign('/' + data[0].id);
} else {
// Todo: create tenant
}
if (data.length > 1) {
return;
}
}, [data]);
if (data[0]) {
window.location.assign('/' + data[0].id);
} else {
void createTenant();
}
}, [createTenant, data]);
if (data.length > 1) {
return (
@ -31,6 +41,8 @@ const Tenants = ({ data }: Props) => {
<Button title={<DangerousRaw>{id}</DangerousRaw>} />
</a>
))}
<h3>Create a tenant</h3>
<Button title={<DangerousRaw>Create New</DangerousRaw>} onClick={createTenant} />
</div>
);
}

View file

@ -33,7 +33,14 @@ const Protected = () => {
return <Redirect tenants={tenants} toTenantId={currentTenantId} />;
}
return <Tenants data={tenants} />;
return (
<Tenants
data={tenants}
onAdd={(tenant) => {
setTenants([...tenants, tenant]);
}}
/>
);
}
return <AppLoadingOffline />;

View file

@ -8,7 +8,8 @@
"../*/lib/",
"../core/src/",
"../core/node_modules/",
".env"
".env",
"../../.env"
],
"ext": "json,js,jsx,ts,tsx",
"delay": 500

View file

@ -35,7 +35,7 @@
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "2.2.0",
"@withtyped/postgres": "^0.8.0",
"@withtyped/postgres": "^0.8.1",
"@withtyped/server": "^0.8.0",
"chalk": "^5.0.0",
"clean-deep": "^3.4.0",

View file

@ -1,14 +1,14 @@
import { tryThat } from '@logto/shared';
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import { assertEnv, getEnv, getEnvAsStringArray, yes } from '@silverhand/essentials';
import UrlSet from './UrlSet.js';
import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage } from './throw-errors.js';
export default class GlobalValues {
public readonly isProduction = getEnv('NODE_ENV') === 'production';
public readonly isTest = getEnv('NODE_ENV') === 'test';
public readonly isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
public readonly isIntegrationTest = yes(getEnv('INTEGRATION_TEST'));
public readonly isCloud = yes(process.env.IS_CLOUD);
public readonly httpsCert = process.env.HTTPS_CERT_PATH;
public readonly httpsKey = process.env.HTTPS_KEY_PATH;
@ -25,8 +25,8 @@ export default class GlobalValues {
public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID');
public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES');
public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID');
public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER'));
public readonly ignoreConnectorVersionCheck = isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK'));
public readonly trustProxyHeader = yes(getEnv('TRUST_PROXY_HEADER'));
public readonly ignoreConnectorVersionCheck = yes(getEnv('IGNORE_CONNECTOR_VERSION_CHECK'));
public get dbUrl(): string {
return this.databaseUrl;

View file

@ -1,12 +1,10 @@
import { deduplicate, getEnv, trySafe } from '@silverhand/essentials';
import { isTrue } from './parameters.js';
import { deduplicate, getEnv, trySafe, yes } from '@silverhand/essentials';
export default class UrlSet {
readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT');
public readonly isLocalhostDisabled = isTrue(getEnv(this.envPrefix + 'DISABLE_LOCALHOST'));
public readonly isLocalhostDisabled = yes(getEnv(this.envPrefix + 'DISABLE_LOCALHOST'));
constructor(
public readonly isHttpsEnabled: boolean,

View file

@ -1,6 +0,0 @@
import type { Nullable } from '@silverhand/essentials';
export const isTrue = (value?: Nullable<string>) =>
// We need to leverage the native type guard
// eslint-disable-next-line no-implicit-coercion
!!value && ['1', 'true', 'y', 'yes', 'yep', 'yeah'].includes(value.toLowerCase());

View file

@ -18,8 +18,11 @@ import { getConstantClientMetadata } from './utils.js';
* as Admin Console is attached to the admin tenant in OSS and its endpoints are dynamic (from env variable).
*/
const transpileMetadata = (clientId: string, data: AllClientMetadata): AllClientMetadata => {
const { adminUrlSet } = EnvSet.values;
const urls = adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString());
const { adminUrlSet, cloudUrlSet } = EnvSet.values;
const urls = [
...adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString()),
...cloudUrlSet.deduplicated().map(String),
];
if (clientId === adminConsoleApplicationId) {
return {

View file

@ -1,9 +1,9 @@
import { buildIdGenerator } from '@logto/core-kit';
import { Resources, Scopes } from '@logto/schemas';
import { tryThat } from '@logto/shared';
import { yes } from '@silverhand/essentials';
import { object, string } from 'zod';
import { isTrue } from '#src/env-set/parameters.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
@ -54,7 +54,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
if (disabled) {
const resources = await findAllResources();
ctx.body = isTrue(includeScopes) ? await attachScopesToResources(resources) : resources;
ctx.body = yes(includeScopes) ? await attachScopesToResources(resources) : resources;
return next();
}
@ -65,7 +65,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
]);
ctx.pagination.totalCount = count;
ctx.body = isTrue(includeScopes) ? await attachScopesToResources(resources) : resources;
ctx.body = yes(includeScopes) ? await attachScopesToResources(resources) : resources;
return next();
}

View file

@ -1,11 +1,9 @@
import { SearchJointMode, SearchMatchMode } from '@logto/schemas';
import type { Nullable, Optional } from '@silverhand/essentials';
import { conditionalString } from '@silverhand/essentials';
import { yes, conditionalString } from '@silverhand/essentials';
import { sql } from 'slonik';
import { snakeCase } from 'snake-case';
import { isTrue } from '#src/env-set/parameters.js';
import assertThat from './assert-that.js';
import { isEnum } from './type.js';
@ -83,7 +81,7 @@ const getSearchMetadata = (searchParameters: URLSearchParams, allowedFields?: st
const matchMode = new Map<Optional<string>, SearchMatchMode>();
const matchValues = new Map<Optional<string>, string[]>();
const joint = getJointMode(searchParameters.get('joint') ?? searchParameters.get('jointMode'));
const isCaseSensitive = isTrue(searchParameters.get('isCaseSensitive') ?? 'false');
const isCaseSensitive = yes(searchParameters.get('isCaseSensitive') ?? 'false');
// Parse the following values and return:
// 1. Search modes per field, if available

View file

@ -24,14 +24,16 @@ export const getTenantId = (url: URL) => {
adminUrlSet,
} = EnvSet.values;
if ((!isProduction || isIntegrationTest) && developmentTenantId) {
return developmentTenantId;
}
if (adminUrlSet.deduplicated().some((endpoint) => isEndpointOf(url, endpoint))) {
return adminTenantId;
}
if ((!isProduction || isIntegrationTest) && developmentTenantId) {
console.log(`Found dev tenant ID ${developmentTenantId}.`);
return developmentTenantId;
}
if (
!isDomainBasedMultiTenancy ||
(!urlSet.isLocalhostDisabled && isEndpointOf(url, urlSet.localhostUrl))

View file

@ -6,6 +6,7 @@ import { AdminConsoleConfigKey } from '../types/index.js';
export const createDefaultAdminConsoleConfig = (
forTenantId: string
): Readonly<{
tenantId: string;
key: AdminConsoleConfigKey;
value: AdminConsoleData;
}> =>

14
pnpm-lock.yaml generated
View file

@ -106,6 +106,7 @@ importers:
packages/cloud:
specifiers:
'@logto/cli': workspace:*
'@logto/core-kit': workspace:*
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
@ -115,7 +116,7 @@ importers:
'@types/http-proxy': ^1.17.9
'@types/mime-types': ^2.1.1
'@types/node': ^18.11.18
'@withtyped/postgres': ^0.8.0
'@withtyped/postgres': ^0.8.1
'@withtyped/server': ^0.8.0
chalk: ^5.0.0
decamelize: ^6.0.0
@ -131,11 +132,12 @@ importers:
typescript: ^4.9.4
zod: ^3.20.2
dependencies:
'@logto/cli': link:../cli
'@logto/core-kit': link:../toolkit/core-kit
'@logto/schemas': link:../schemas
'@logto/shared': link:../shared
'@silverhand/essentials': 2.2.0
'@withtyped/postgres': 0.8.0_@withtyped+server@0.8.0
'@withtyped/postgres': 0.8.1_@withtyped+server@0.8.0
'@withtyped/server': 0.8.0
chalk: 5.1.2
decamelize: 6.0.0
@ -333,7 +335,7 @@ importers:
'@types/semver': ^7.3.12
'@types/sinon': ^10.0.13
'@types/supertest': ^2.0.11
'@withtyped/postgres': ^0.8.0
'@withtyped/postgres': ^0.8.1
'@withtyped/server': ^0.8.0
chalk: ^5.0.0
clean-deep: ^3.4.0
@ -393,7 +395,7 @@ importers:
'@logto/schemas': link:../schemas
'@logto/shared': link:../shared
'@silverhand/essentials': 2.2.0
'@withtyped/postgres': 0.8.0_@withtyped+server@0.8.0
'@withtyped/postgres': 0.8.1_@withtyped+server@0.8.0
'@withtyped/server': 0.8.0
chalk: 5.1.2
clean-deep: 3.4.0
@ -4563,8 +4565,8 @@ packages:
eslint-visitor-keys: 3.3.0
dev: true
/@withtyped/postgres/0.8.0_@withtyped+server@0.8.0:
resolution: {integrity: sha512-cyhHR1lEV1cs0yDfmHvqR4R52kxgI/JCDd2fizOCzgIE6Z9g3GjYjeVtTl/fTaoDz/QDaXKwfkuxd0N7HSY7uw==}
/@withtyped/postgres/0.8.1_@withtyped+server@0.8.0:
resolution: {integrity: sha512-BkX1SPDV8bZFn1LEI6jzTes+y/4BGuPEBOi8p6jH9ZBySTuaW0/JqgEb1aKk5GtJ0aFGXhgq4Fk+JkLOc6zehA==}
peerDependencies:
'@withtyped/server': ^0.8.0
dependencies: