diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index 41d3709d3..be2778e73 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -10,6 +10,7 @@ import { createMeApiInAdminTenant, createDefaultSignInExperience, createAdminTenantSignInExperience, + createDefaultAdminConsoleApplication, } from '@logto/schemas'; import { Hooks, Tenants } from '@logto/schemas/models'; import type { DatabaseTransactionConnection } from 'slonik'; @@ -128,6 +129,7 @@ export const seedTables = async ( insertInto(createDefaultSignInExperience(defaultTenantId), 'sign_in_experiences') ), connection.query(insertInto(createAdminTenantSignInExperience(), 'sign_in_experiences')), + connection.query(insertInto(createDefaultAdminConsoleApplication(), 'applications')), updateDatabaseTimestamp(connection, latestTimestamp), ]); }; diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index 10ca3ff09..601db2e73 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -13,7 +13,7 @@ import './scss/overlayscrollbars.scss'; import '@fontsource/roboto-mono'; import AppLoading from '@/components/AppLoading'; import Toast from '@/components/Toast'; -import { managementApi, meApi } from '@/consts/management-api'; +import { getManagementApi, meApi } from '@/consts/management-api'; import AppBoundary from '@/containers/AppBoundary'; import AppLayout from '@/containers/AppLayout'; import ErrorBoundary from '@/containers/ErrorBoundary'; @@ -48,6 +48,7 @@ import { UserDetailsTabs, adminTenantEndpoint, getUserTenantId, + getBasename, } from './consts'; import { isCloud } from './consts/cloud'; import AppContent from './containers/AppContent'; @@ -161,26 +162,29 @@ const Main = () => { ); }; -const App = () => ( - - - -
- - - -); +const App = () => { + const managementApi = getManagementApi(getUserTenantId()); + return ( + + + +
+ + + + ); +}; export default App; diff --git a/packages/console/src/consts/management-api.ts b/packages/console/src/consts/management-api.ts index 4bb637896..76fe76dd0 100644 --- a/packages/console/src/consts/management-api.ts +++ b/packages/console/src/consts/management-api.ts @@ -1,14 +1,10 @@ -import { - adminTenantId, - defaultTenantId, - getManagementApiResourceIndicator, - PredefinedScope, -} from '@logto/schemas'; +import { adminTenantId, getManagementApiResourceIndicator, PredefinedScope } from '@logto/schemas'; -export const managementApi = Object.freeze({ - indicator: getManagementApiResourceIndicator(defaultTenantId), - scopeAll: PredefinedScope.All, -}); +export const getManagementApi = (tenantId: string) => + Object.freeze({ + indicator: getManagementApiResourceIndicator(tenantId), + scopeAll: PredefinedScope.All, + }); export const meApi = Object.freeze({ indicator: getManagementApiResourceIndicator(adminTenantId, 'me'), diff --git a/packages/console/src/consts/tenants.ts b/packages/console/src/consts/tenants.ts index b9b6d5009..156a585ac 100644 --- a/packages/console/src/consts/tenants.ts +++ b/packages/console/src/consts/tenants.ts @@ -1,6 +1,19 @@ +import { defaultTenantId, ossConsolePath } from '@logto/schemas'; + +import { isCloud } from './cloud'; + const isProduction = process.env.NODE_ENV === 'production'; export const adminTenantEndpoint = process.env.ADMIN_TENANT_ENDPOINT ?? (isProduction ? window.location.origin : 'http://localhost:3002'); -export const getUserTenantId = () => window.location.pathname.split('/')[1]; + +export const getUserTenantId = () => { + if (isCloud) { + return window.location.pathname.split('/')[1] ?? ''; + } + + return defaultTenantId; +}; + +export const getBasename = () => (isCloud ? '/' + getUserTenantId() : ossConsolePath); diff --git a/packages/console/src/hooks/use-api.ts b/packages/console/src/hooks/use-api.ts index d87c6361e..802a58165 100644 --- a/packages/console/src/hooks/use-api.ts +++ b/packages/console/src/hooks/use-api.ts @@ -5,7 +5,7 @@ import { useCallback, useContext, useMemo } from 'react'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import { managementApi, requestTimeout } from '@/consts'; +import { getManagementApi, getUserTenantId, requestTimeout } from '@/consts'; import { AppEndpointsContext } from '@/containers/AppEndpointsProvider'; export class RequestError extends Error { @@ -28,7 +28,7 @@ type StaticApiProps = { export const useStaticApi = ({ prefixUrl, hideErrorToast, - resourceIndicator = managementApi.indicator, + resourceIndicator = getManagementApi(getUserTenantId()).indicator, }: StaticApiProps) => { const { isAuthenticated, getAccessToken } = useLogto(); const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' }); diff --git a/packages/core/src/middleware/koa-console-redirect-proxy.ts b/packages/core/src/middleware/koa-console-redirect-proxy.ts index a6fb817ed..e6a8da3c9 100644 --- a/packages/core/src/middleware/koa-console-redirect-proxy.ts +++ b/packages/core/src/middleware/koa-console-redirect-proxy.ts @@ -1,3 +1,6 @@ +import path from 'path'; + +import { ossConsolePath } from '@logto/schemas'; import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -13,14 +16,14 @@ export default function koaConsoleRedirectProxy< return async (ctx, next) => { const hasUser = await hasActiveUsers(); - if ((ctx.path === '/' || ctx.path === '/console') && !hasUser) { - ctx.redirect('/console/welcome'); + if ((ctx.path === '/' || ctx.path === ossConsolePath) && !hasUser) { + ctx.redirect(path.join(ossConsolePath, '/welcome')); return; } - if ((ctx.path === '/' || ctx.path === '/console/welcome') && hasUser) { - ctx.redirect('/console'); + if ((ctx.path === '/' || ctx.path === path.join(ossConsolePath, '/welcome')) && hasUser) { + ctx.redirect(ossConsolePath); return; } diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index e5da0f2e3..11b985f05 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -13,21 +13,26 @@ import { appendPath } from '#src/utils/url.js'; import { getConstantClientMetadata } from './utils.js'; -const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => { - const { adminUrlSet, cloudUrlSet } = EnvSet.values; - const urls = [ - ...adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString()), - // Logto Cloud uses `https://some.cloud.endpoint/[tenantId]` to serve Admin Console for specific Tenant ID - ...cloudUrlSet.deduplicated().map((url) => appendPath(url, '/' + envSet.tenantId).toString()), - ]; +/** + * Append `redirect_uris` and `post_logout_redirect_uris` for Admin Console + * 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()); - return { - ...getConstantClientMetadata(envSet, ApplicationType.SPA), - client_id: adminConsoleApplicationId, - client_name: 'Admin Console', - redirect_uris: urls.map((url) => appendPath(url, '/callback').toString()), - post_logout_redirect_uris: urls, - }; + if (clientId === adminConsoleApplicationId) { + return { + ...data, + redirect_uris: [ + ...(data.redirect_uris ?? []), + ...urls.map((url) => appendPath(url, '/callback').toString()), + ], + post_logout_redirect_uris: [...(data.post_logout_redirect_uris ?? []), ...urls], + }; + } + + return data; }; const buildDemoAppClientMetadata = (envSet: EnvSet): AllClientMetadata => { @@ -77,7 +82,7 @@ export default function postgresAdapter( client_secret, client_name, ...getConstantClientMetadata(envSet, type), - ...snakecaseKeys(oidcClientMetadata), + ...transpileMetadata(client_id, snakecaseKeys(oidcClientMetadata)), // `node-oidc-provider` won't camelCase custom parameter keys, so we need to keep the keys camelCased ...customClientMetadata, }); @@ -85,11 +90,6 @@ export default function postgresAdapter( return { upsert: reject, find: async (id) => { - // Directly return client metadata since Admin Console does not belong to any tenant in the OSS version. - if (id === adminConsoleApplicationId) { - return buildAdminConsoleClientMetadata(envSet); - } - if (id === demoAppApplicationId) { return buildDemoAppClientMetadata(envSet); } diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 23f6264ee..5628e3438 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -83,14 +83,17 @@ export default class Tenant implements TenantContext { // Mount `/me` APIs for admin tenant app.use(mount('/me', initMeApis(tenantContext))); - // Mount Admin Console - app.use(koaConsoleRedirectProxy(queries)); - app.use( - mount( - '/' + AdminApps.Console, - koaSpaProxy(mountedApps, AdminApps.Console, 5002, AdminApps.Console) - ) - ); + // Mount Admin Console when needed + // Skip in domain-based multi-tenancy since Logto Cloud serves Admin Console in this case + if (!EnvSet.values.isDomainBasedMultiTenancy) { + app.use(koaConsoleRedirectProxy(queries)); + app.use( + mount( + '/' + AdminApps.Console, + koaSpaProxy(mountedApps, AdminApps.Console, 5002, AdminApps.Console) + ) + ); + } } else { // Mount demo app app.use( diff --git a/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts b/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts index 829615d09..01d9fb0b2 100644 --- a/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts +++ b/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts @@ -40,12 +40,17 @@ const data = { ], }, socialSignInConnectorTargets: [], - signInMode: 'Register', customCss: null, -}; +} as const; const alteration: AlterationScript = { up: async (pool) => { + const hasActiveUsers = await pool.exists(sql` + select id + from users + where tenant_id = 'default' + limit 1 + `); await pool.query(sql` insert into sign_in_experiences ( tenant_id, @@ -69,7 +74,7 @@ const alteration: AlterationScript = { ${sql.jsonb(data.signUp)}, ${sql.jsonb(data.signIn)}, ${sql.jsonb(data.socialSignInConnectorTargets)}, - ${data.signInMode}, + ${hasActiveUsers ? 'SignIn' : 'Register'}, ${data.customCss} ); `); diff --git a/packages/schemas/alterations/next-1677059985-move-console-application-to-database.ts b/packages/schemas/alterations/next-1677059985-move-console-application-to-database.ts new file mode 100644 index 000000000..aeb88bfa9 --- /dev/null +++ b/packages/schemas/alterations/next-1677059985-move-console-application-to-database.ts @@ -0,0 +1,37 @@ +import { generateStandardId } from '@logto/core-kit'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + insert into applications ( + tenant_id, + id, + name, + secret, + description, + type, + oidc_client_metadata + ) values ( + 'admin', + 'admin-console', + 'Admin Console', + ${generateStandardId()}, + 'Logto Admin Console.', + 'SPA', + '{ "redirectUris": [], "postLogoutRedirectUris": [] }'::jsonb + ); + `); + }, + down: async (pool) => { + await pool.query(sql` + delete from applications + where tenant_id = 'admin' + and id = 'admin-console'; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/consts/cloud.ts b/packages/schemas/src/consts/cloud.ts deleted file mode 100644 index a8cc30c30..000000000 --- a/packages/schemas/src/consts/cloud.ts +++ /dev/null @@ -1 +0,0 @@ -export const cloudApiIndicator = 'https://cloud.logto.io/api'; diff --git a/packages/schemas/src/consts/index.ts b/packages/schemas/src/consts/index.ts index 0a3d23633..723c62852 100644 --- a/packages/schemas/src/consts/index.ts +++ b/packages/schemas/src/consts/index.ts @@ -1 +1,2 @@ -export * from './cloud.js'; +export * from './system.js'; +export * from './oidc.js'; diff --git a/packages/schemas/src/consts/oidc.ts b/packages/schemas/src/consts/oidc.ts new file mode 100644 index 000000000..7131ee772 --- /dev/null +++ b/packages/schemas/src/consts/oidc.ts @@ -0,0 +1 @@ +export const tenantIdKey = 'tenant_id'; diff --git a/packages/schemas/src/consts/system.ts b/packages/schemas/src/consts/system.ts new file mode 100644 index 000000000..ab2054252 --- /dev/null +++ b/packages/schemas/src/consts/system.ts @@ -0,0 +1,14 @@ +/** The API Resource Indicator for Logto Cloud. It's only useful when domain-based multi-tenancy is enabled. */ +export const cloudApiIndicator = 'https://cloud.logto.io/api'; + +/** + * In OSS: + * + * - Only one single user tenant (`default`) is available. + * - Admin tenant and Admin Console share one endpoint (`ADMIN_ENDPOINT`). + * + * There's no need to parse tenant ID from the first path segment in OSS, and the segment should be a fixed value. + * + * If we use `/default`, the URL will look ugly; thus we keep the old fashion `/console`. + */ +export const ossConsolePath = '/console'; diff --git a/packages/schemas/src/seeds/application.ts b/packages/schemas/src/seeds/application.ts index 2f2c5aa41..b235d233f 100644 --- a/packages/schemas/src/seeds/application.ts +++ b/packages/schemas/src/seeds/application.ts @@ -1,3 +1,9 @@ +import { generateStandardId } from '@logto/core-kit'; + +import type { CreateApplication } from '../db-entries/index.js'; +import { ApplicationType } from '../db-entries/index.js'; +import { adminTenantId } from './tenant.js'; + /** * The fixed application ID for Admin Console. * @@ -6,3 +12,14 @@ export const adminConsoleApplicationId = 'admin-console'; export const demoAppApplicationId = 'demo-app'; + +export const createDefaultAdminConsoleApplication = (): Readonly => + Object.freeze({ + tenantId: adminTenantId, + id: adminConsoleApplicationId, + name: 'Admin Console', + secret: generateStandardId(), + description: 'Logto Admin Console.', + type: ApplicationType.SPA, + oidcClientMetadata: { redirectUris: [], postLogoutRedirectUris: [] }, + });