mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(cloud): GET /api/tenants
This commit is contained in:
parent
8fa9e7a4a1
commit
1808a25570
19 changed files with 190 additions and 27 deletions
|
@ -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] })
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -1,16 +1,29 @@
|
|||
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(
|
||||
composer: compose(withRequest())
|
||||
.and(router.routes())
|
||||
.and(
|
||||
isProduction
|
||||
? withSpa({ pathname: '/console', root: '../console/dist' })
|
||||
: withHttpProxy('/console', { target: 'http://localhost:5002', changeOrigin: true })
|
||||
? withSpa({ pathname: '/', root: '../console/dist', ignorePathnames })
|
||||
: withHttpProxy('/', {
|
||||
target: 'http://localhost:5002',
|
||||
changeOrigin: true,
|
||||
ignorePathnames,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
|
|
|
@ -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<InputContext extends RequestContext>(
|
||||
pathname: string,
|
||||
options: ServerOptions
|
||||
{ ignorePathnames, ...options }: WithHttpProxyOptions
|
||||
) {
|
||||
const proxy = createProxy(options);
|
||||
|
||||
|
@ -31,7 +38,7 @@ export default function withHttpProxy<InputContext extends RequestContext>(
|
|||
|
||||
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<InputContext extends RequestContext>(
|
|||
return next({ ...context, status: 'ignore' });
|
||||
};
|
||||
}
|
||||
|
||||
export type { ServerOptions } from 'http-proxy';
|
||||
|
|
|
@ -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<InputContext extends RequestContext>({
|
|||
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<InputContext extends RequestContext>({
|
|||
|
||||
const pathname = matchPathname(rootPathname, url.pathname);
|
||||
|
||||
if (!pathname) {
|
||||
if (!pathname || ignorePathnames?.some((prefix) => matchPathname(prefix, url.pathname))) {
|
||||
return next(context);
|
||||
}
|
||||
|
||||
|
|
5
packages/cloud/src/queries/index.ts
Normal file
5
packages/cloud/src/queries/index.ts
Normal file
|
@ -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));
|
24
packages/cloud/src/queries/tenants.ts
Normal file
24
packages/cloud/src/queries/tenants.ts
Normal file
|
@ -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 };
|
||||
};
|
7
packages/cloud/src/routes/index.ts
Normal file
7
packages/cloud/src/routes/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { createRouter } from '@withtyped/server';
|
||||
|
||||
import { tenants } from './tenants.js';
|
||||
|
||||
const router = createRouter('/api').pack(tenants);
|
||||
|
||||
export default router;
|
20
packages/cloud/src/routes/tenants.ts
Normal file
20
packages/cloud/src/routes/tenants.ts
Normal file
|
@ -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 });
|
||||
});
|
63
packages/cloud/src/utils/postgres.ts
Normal file
63
packages/cloud/src/utils/postgres.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import type { createQueryClient } from '@withtyped/postgres';
|
||||
|
||||
type CreateClientConfig = Parameters<typeof createQueryClient>[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<CreateClientConfig> = {};
|
||||
|
||||
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 */
|
9
packages/cloud/src/utils/tenant.ts
Normal file
9
packages/cloud/src/utils/tenant.ts
Normal file
|
@ -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];
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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\""
|
||||
|
|
|
@ -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 = () => (
|
||||
<BrowserRouter basename={getBasename('console', '5002')}>
|
||||
<BrowserRouter basename={`/${getUserTenantId() ?? ''}`}>
|
||||
<AppEndpointsProvider>
|
||||
<LogtoProvider
|
||||
config={{
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { defaultTenantId } from '@logto/schemas';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export const adminTenantEndpoint =
|
||||
process.env.ADMIN_TENANT_ENDPOINT ??
|
||||
(isProduction ? window.location.origin : 'http://localhost:3002');
|
||||
export const userTenantId = process.env.USER_TENANT_ID ?? defaultTenantId;
|
||||
export const getUserTenantId = () => window.location.pathname.split('/')[1];
|
||||
|
|
|
@ -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) });
|
||||
};
|
||||
|
|
|
@ -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 : '';
|
||||
};
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue