0
Fork 0
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:
Gao Sun 2023-02-18 18:37:49 +08:00
parent 8fa9e7a4a1
commit 1808a25570
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
19 changed files with 190 additions and 27 deletions

View file

@ -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] })
},

View file

@ -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",

View file

@ -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"
},

View file

@ -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) => {

View file

@ -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';

View file

@ -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);
}

View 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));

View 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 };
};

View file

@ -0,0 +1,7 @@
import { createRouter } from '@withtyped/server';
import { tenants } from './tenants.js';
const router = createRouter('/api').pack(tenants);
export default router;

View 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 });
});

View 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 */

View 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];
};

View file

@ -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);
}

View file

@ -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\""

View file

@ -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={{

View file

@ -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];

View file

@ -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) });
};

View file

@ -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 : '';
};

View file

@ -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: