From 1808a25570d8e48294d7d5d2c8baa286360b9064 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 18 Feb 2023 18:37:49 +0800 Subject: [PATCH] feat(cloud): `GET /api/tenants` --- commitlint.config.cjs | 2 +- package.json | 2 +- packages/cloud/package.json | 3 + packages/cloud/src/index.ts | 23 +++++-- .../cloud/src/middleware/with-http-proxy.ts | 13 ++-- packages/cloud/src/middleware/with-spa.ts | 7 ++- packages/cloud/src/queries/index.ts | 5 ++ packages/cloud/src/queries/tenants.ts | 24 +++++++ packages/cloud/src/routes/index.ts | 7 +++ packages/cloud/src/routes/tenants.ts | 20 ++++++ packages/cloud/src/utils/postgres.ts | 63 +++++++++++++++++++ packages/cloud/src/utils/tenant.ts | 9 +++ packages/cloud/src/utils/url.ts | 4 ++ packages/console/package.json | 4 +- packages/console/src/App.tsx | 4 +- packages/console/src/consts/tenants.ts | 5 +- .../containers/AppEndpointsProvider/index.tsx | 10 ++- packages/console/src/utils/router.ts | 6 -- pnpm-lock.yaml | 6 ++ 19 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 packages/cloud/src/queries/index.ts create mode 100644 packages/cloud/src/queries/tenants.ts create mode 100644 packages/cloud/src/routes/index.ts create mode 100644 packages/cloud/src/routes/tenants.ts create mode 100644 packages/cloud/src/utils/postgres.ts create mode 100644 packages/cloud/src/utils/tenant.ts delete mode 100644 packages/console/src/utils/router.ts diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 3c50b3ec9..561415027 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -7,7 +7,7 @@ module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [...rules['type-enum'][2], 'api', 'release']], - 'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'ui', 'deps','cli', 'toolkit']], + 'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'ui', 'deps','cli', 'toolkit', 'cloud']], // Slightly increase the tolerance to allow the appending PR number ...(isCi && { 'header-max-length': [2, 'always', 110] }) }, diff --git a/package.json b/package.json index 2159083da..d35dfa86e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "pnpm -r prepack && pnpm start:dev", "dev:cloud": "pnpm -r prepack && pnpm start:dev:cloud", "start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter=!@logto/cloud dev", - "start:dev:cloud": "pnpm -r --parallel --filter=!@logto/integration-tests dev", + "start:dev:cloud": "CONSOLE_PUBLIC_URL=/ pnpm -r --parallel --filter=!@logto/integration-tests dev", "start": "cd packages/core && NODE_ENV=production node .", "cli": "logto", "alteration": "logto db alt", diff --git a/packages/cloud/package.json b/packages/cloud/package.json index d9d70dd1f..f303957c6 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -19,11 +19,14 @@ "start": "NODE_ENV=production node build/index.js" }, "dependencies": { + "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", "@silverhand/essentials": "2.2.0", "@withtyped/postgres": "^0.6.0", "@withtyped/server": "^0.6.0", "chalk": "^5.0.0", + "dotenv": "^16.0.0", + "find-up": "^6.3.0", "http-proxy": "^1.18.1", "mime-types": "^2.1.35" }, diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index af3bbbe3a..fd90749c4 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,17 +1,30 @@ import createServer, { compose, withRequest } from '@withtyped/server'; +import dotenv from 'dotenv'; +import { findUp } from 'find-up'; import withHttpProxy from './middleware/with-http-proxy.js'; import withSpa from './middleware/with-spa.js'; +dotenv.config({ path: await findUp('.env', {}) }); + +const { default: router } = await import('./routes/index.js'); + const isProduction = process.env.NODE_ENV === 'production'; +const ignorePathnames = ['/api']; const { listen } = createServer({ port: 3003, - composer: compose(withRequest()).and( - isProduction - ? withSpa({ pathname: '/console', root: '../console/dist' }) - : withHttpProxy('/console', { target: 'http://localhost:5002', changeOrigin: true }) - ), + composer: compose(withRequest()) + .and(router.routes()) + .and( + isProduction + ? withSpa({ pathname: '/', root: '../console/dist', ignorePathnames }) + : withHttpProxy('/', { + target: 'http://localhost:5002', + changeOrigin: true, + ignorePathnames, + }) + ), }); await listen((port) => { diff --git a/packages/cloud/src/middleware/with-http-proxy.ts b/packages/cloud/src/middleware/with-http-proxy.ts index b3c6fdee0..82eab2ce4 100644 --- a/packages/cloud/src/middleware/with-http-proxy.ts +++ b/packages/cloud/src/middleware/with-http-proxy.ts @@ -7,9 +7,16 @@ import { matchPathname } from '#src/utils/url.js'; const { createProxy } = HttpProxy; +export type WithHttpProxyOptions = ServerOptions & { + /** + * An array of pathname prefixes to ignore. + */ + ignorePathnames?: string[]; +}; + export default function withHttpProxy( pathname: string, - options: ServerOptions + { ignorePathnames, ...options }: WithHttpProxyOptions ) { const proxy = createProxy(options); @@ -31,7 +38,7 @@ export default function withHttpProxy( const matched = matchPathname(pathname, url.pathname); - if (!matched) { + if (!matched || ignorePathnames?.some((prefix) => matchPathname(prefix, url.pathname))) { return next(context); } @@ -43,5 +50,3 @@ export default function withHttpProxy( return next({ ...context, status: 'ignore' }); }; } - -export type { ServerOptions } from 'http-proxy'; diff --git a/packages/cloud/src/middleware/with-spa.ts b/packages/cloud/src/middleware/with-spa.ts index 788f1bf1f..0968e6dae 100644 --- a/packages/cloud/src/middleware/with-spa.ts +++ b/packages/cloud/src/middleware/with-spa.ts @@ -21,6 +21,10 @@ export type WithSpaConfig = { * @default '/' */ pathname?: string; + /** + * An array of pathname prefixes to ignore. + */ + ignorePathnames?: string[]; /** * The path to file to serve when the given path cannot be found in the file system. * @default 'index.html' @@ -32,6 +36,7 @@ export default function withSpa({ maxAge = 604_800, root, pathname: rootPathname = '/', + ignorePathnames, indexPath: index = 'index.html', }: WithSpaConfig) { assert(root, new Error('Root directory is required to serve files.')); @@ -44,7 +49,7 @@ export default function withSpa({ const pathname = matchPathname(rootPathname, url.pathname); - if (!pathname) { + if (!pathname || ignorePathnames?.some((prefix) => matchPathname(prefix, url.pathname))) { return next(context); } diff --git a/packages/cloud/src/queries/index.ts b/packages/cloud/src/queries/index.ts new file mode 100644 index 000000000..80d15f9db --- /dev/null +++ b/packages/cloud/src/queries/index.ts @@ -0,0 +1,5 @@ +import { createQueryClient } from '@withtyped/postgres'; + +import { parseDsn } from '#src/utils/postgres.js'; + +export const client = createQueryClient(parseDsn(process.env.DB_URL)); diff --git a/packages/cloud/src/queries/tenants.ts b/packages/cloud/src/queries/tenants.ts new file mode 100644 index 000000000..8a3212d5f --- /dev/null +++ b/packages/cloud/src/queries/tenants.ts @@ -0,0 +1,24 @@ +import { adminTenantId, getManagementApiResourceIndicator, PredefinedScope } from '@logto/schemas'; +import type { PostgresQueryClient } from '@withtyped/postgres'; +import { sql } from '@withtyped/postgres'; + +export const createTenantsQueries = (client: PostgresQueryClient) => { + const getManagementApiLikeIndicatorsForUser = async (userId: string) => + client.query<{ indicator: string }>(sql` + select resources.indicator from roles + join users_roles + on users_roles.role_id = roles.id + and users_roles.user_id = ${userId} + join roles_scopes + on roles.id = roles_scopes.role_id + join scopes + on roles_scopes.scope_id = scopes.id + and scopes.name = ${PredefinedScope.All} + join resources + on scopes.resource_id = resources.id + and resources.indicator like ${getManagementApiResourceIndicator('%')} + where roles.tenant_id = ${adminTenantId}; + `); + + return { getManagementApiLikeIndicatorsForUser }; +}; diff --git a/packages/cloud/src/routes/index.ts b/packages/cloud/src/routes/index.ts new file mode 100644 index 000000000..2d2269d8b --- /dev/null +++ b/packages/cloud/src/routes/index.ts @@ -0,0 +1,7 @@ +import { createRouter } from '@withtyped/server'; + +import { tenants } from './tenants.js'; + +const router = createRouter('/api').pack(tenants); + +export default router; diff --git a/packages/cloud/src/routes/tenants.ts b/packages/cloud/src/routes/tenants.ts new file mode 100644 index 000000000..d6943907a --- /dev/null +++ b/packages/cloud/src/routes/tenants.ts @@ -0,0 +1,20 @@ +import { Router } from '@withtyped/server'; + +import { client } from '#src/queries/index.js'; +import { createTenantsQueries } from '#src/queries/tenants.js'; +import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js'; + +const { getManagementApiLikeIndicatorsForUser } = createTenantsQueries(client); + +export const tenants = new Router('/tenants').get('/', {}, async (context, next) => { + const { rows } = await getManagementApiLikeIndicatorsForUser('some_user_id'); + + const tenants = rows + .map(({ indicator }) => ({ + id: getTenantIdFromManagementApiIndicator(indicator), + indicator, + })) + .filter((tenant): tenant is { id: string; indicator: string } => Boolean(tenant.id)); + + return next({ ...context, json: tenants }); +}); diff --git a/packages/cloud/src/utils/postgres.ts b/packages/cloud/src/utils/postgres.ts new file mode 100644 index 000000000..d0a0cae41 --- /dev/null +++ b/packages/cloud/src/utils/postgres.ts @@ -0,0 +1,63 @@ +import type { createQueryClient } from '@withtyped/postgres'; + +type CreateClientConfig = Parameters[0]; + +/* eslint-disable @silverhand/fp/no-mutation */ +// Edited from https://github.com/gajus/slonik/blob/d66b76c44638c8b424cea55475d6e1385c2caae8/src/utilities/parseDsn.ts +export const parseDsn = (dsn?: string): CreateClientConfig => { + if (!dsn?.trim()) { + return; + } + + const url = new URL(dsn); + + const config: NonNullable = {}; + + if (url.host) { + config.host = decodeURIComponent(url.hostname); + } + + if (url.port) { + config.port = Number(url.port); + } + + const database = url.pathname.split('/')[1]; + + if (database) { + config.database = decodeURIComponent(database); + } + + if (url.username) { + config.user = decodeURIComponent(url.username); + } + + if (url.password) { + config.password = decodeURIComponent(url.password); + } + + const { + application_name: applicationName, + sslmode: sslMode, + ...unsupportedOptions + } = Object.fromEntries(url.searchParams); + + if (Object.keys(unsupportedOptions).length > 0) { + console.warn( + { + unsupportedOptions, + }, + 'unsupported DSN parameters' + ); + } + + if (applicationName) { + config.application_name = applicationName; + } + + if (sslMode === 'require') { + config.ssl = true; + } + + return config; +}; +/* eslint-enable @silverhand/fp/no-mutation */ diff --git a/packages/cloud/src/utils/tenant.ts b/packages/cloud/src/utils/tenant.ts new file mode 100644 index 000000000..a2413ece0 --- /dev/null +++ b/packages/cloud/src/utils/tenant.ts @@ -0,0 +1,9 @@ +import { getManagementApiResourceIndicator } from '@logto/schemas'; +import { trySafe } from '@silverhand/essentials'; + +export const getTenantIdFromManagementApiIndicator = (indicator: string) => { + const toMatch = '^' + getManagementApiResourceIndicator('([^.]*)') + '$'; + const url = trySafe(() => new URL(indicator)); + + return url && new RegExp(toMatch).exec(url.href)?.[1]; +}; diff --git a/packages/cloud/src/utils/url.ts b/packages/cloud/src/utils/url.ts index 5fbb26998..9a54000e5 100644 --- a/packages/cloud/src/utils/url.ts +++ b/packages/cloud/src/utils/url.ts @@ -14,6 +14,10 @@ export const matchPathname = (toMatch: string, pathname: string) => { return '/'; } + if (toMatchPathname === '/') { + return normalized; + } + if (normalized.startsWith(toMatchPathname + '/')) { return normalized.slice(toMatchPathname.length); } diff --git a/packages/console/package.json b/packages/console/package.json index dda0ef273..60a6cc918 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -10,9 +10,9 @@ "scripts": { "precommit": "lint-staged", "start": "parcel src/index.html", - "dev": "cross-env PORT=5002 parcel src/index.html --public-url /console --no-cache --hmr-port 6002", + "dev": "cross-env PORT=5002 parcel src/index.html --public-url ${CONSOLE_PUBLIC_URL:-/console} --no-cache --hmr-port 6002", "check": "tsc --noEmit", - "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall --no-cache --public-url /console", + "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall --no-cache --public-url ${CONSOLE_PUBLIC_URL:-/console}", "lint": "eslint --ext .ts --ext .tsx src", "lint:report": "pnpm lint --format json --output-file report.json", "stylelint": "stylelint \"src/**/*.scss\"" diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index 34d0f041d..89712a163 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -46,6 +46,7 @@ import { SignInExperiencePage, UserDetailsTabs, adminTenantEndpoint, + getUserTenantId, } from './consts'; import AppContent from './containers/AppContent'; import AppEndpointsProvider, { AppEndpointsContext } from './containers/AppEndpointsProvider'; @@ -63,7 +64,6 @@ import RoleUsers from './pages/RoleDetails/RoleUsers'; import UserLogs from './pages/UserDetails/UserLogs'; import UserRoles from './pages/UserDetails/UserRoles'; import UserSettings from './pages/UserDetails/UserSettings'; -import { getBasename } from './utils/router'; void initI18n(); @@ -171,7 +171,7 @@ const Main = () => { }; const App = () => ( - + window.location.pathname.split('/')[1]; diff --git a/packages/console/src/containers/AppEndpointsProvider/index.tsx b/packages/console/src/containers/AppEndpointsProvider/index.tsx index b83f90396..81b9e2015 100644 --- a/packages/console/src/containers/AppEndpointsProvider/index.tsx +++ b/packages/console/src/containers/AppEndpointsProvider/index.tsx @@ -2,7 +2,7 @@ import ky from 'ky'; import type { ReactNode } from 'react'; import { useMemo, useEffect, createContext, useState } from 'react'; -import { adminTenantEndpoint, userTenantId } from '@/consts'; +import { adminTenantEndpoint, getUserTenantId } from '@/consts'; type Props = { children: ReactNode; @@ -23,8 +23,14 @@ const AppEndpointsProvider = ({ children }: Props) => { useEffect(() => { const getEndpoint = async () => { + const tenantId = getUserTenantId(); + + if (!tenantId) { + return; + } + const { user } = await ky - .get(new URL(`api/.well-known/endpoints/${userTenantId}`, adminTenantEndpoint)) + .get(new URL(`api/.well-known/endpoints/${tenantId}`, adminTenantEndpoint)) .json<{ user: string }>(); setEndpoints({ userEndpoint: new URL(user) }); }; diff --git a/packages/console/src/utils/router.ts b/packages/console/src/utils/router.ts deleted file mode 100644 index 11c2ff27e..000000000 --- a/packages/console/src/utils/router.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const getBasename = (prefix: string, developmentPort: string): string => { - const isBasenameNeeded = - process.env.NODE_ENV !== 'development' || process.env.PORT === developmentPort; - - return isBasenameNeeded ? '/' + prefix : ''; -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9e564cb..054a3f029 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,7 @@ importers: packages/cloud: specifiers: + '@logto/schemas': workspace:* '@logto/shared': workspace:* '@silverhand/eslint-config': 2.0.1 '@silverhand/essentials': 2.2.0 @@ -116,7 +117,9 @@ importers: '@withtyped/postgres': ^0.6.0 '@withtyped/server': ^0.6.0 chalk: ^5.0.0 + dotenv: ^16.0.0 eslint: ^8.21.0 + find-up: ^6.3.0 http-proxy: ^1.18.1 lint-staged: ^13.0.0 mime-types: ^2.1.35 @@ -124,11 +127,14 @@ importers: prettier: ^2.8.1 typescript: ^4.9.4 dependencies: + '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 2.2.0 '@withtyped/postgres': 0.6.0_@withtyped+server@0.6.0 '@withtyped/server': 0.6.0 chalk: 5.1.2 + dotenv: 16.0.0 + find-up: 6.3.0 http-proxy: 1.18.1 mime-types: 2.1.35 devDependencies: