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: [] },
+ });