mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
Merge branch 'master' into gao-merge-beta-16
This commit is contained in:
commit
73cb2360e0
529 changed files with 9272 additions and 4377 deletions
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
@ -90,6 +90,7 @@ jobs:
|
|||
with:
|
||||
# Set Git operations with the bot PAT since we have tag protection rule
|
||||
token: ${{ secrets.BOT_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node and pnpm
|
||||
uses: silverhand-io/actions-node-pnpm-run-steps@v2
|
||||
|
|
1
.npmrc
1
.npmrc
|
@ -3,3 +3,4 @@ public-hoist-pattern[]=@parcel/*
|
|||
public-hoist-pattern[]=postcss
|
||||
public-hoist-pattern[]=process
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=buffer
|
||||
|
|
|
@ -68,7 +68,7 @@ npm init @logto
|
|||
## Language support
|
||||
|
||||
```ts
|
||||
const languages = ['English', 'Français', 'Português', '简体中文', 'Türkçe', '한국어'];
|
||||
const languages = ['Deutsch', 'English', 'Français', 'Português', '简体中文', 'Türkçe', '한국어'];
|
||||
```
|
||||
|
||||
## Bug report, feature request, feedback
|
||||
|
|
44
docker-compose.uffizzi.yml
Normal file
44
docker-compose.uffizzi.yml
Normal file
|
@ -0,0 +1,44 @@
|
|||
# This compose file is for demonstration only, do not use in prod.
|
||||
version: "3.9"
|
||||
|
||||
x-uffizzi:
|
||||
ingress:
|
||||
service: app
|
||||
port: 3001
|
||||
continuous_previews:
|
||||
deploy_preview_when_pull_request_is_opened: true
|
||||
delete_preview_when_pull_request_is_closed: true
|
||||
share_to_github: true
|
||||
|
||||
services:
|
||||
app:
|
||||
depends_on:
|
||||
- "postgres"
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: ./Dockerfile
|
||||
ports:
|
||||
- 3001:3001
|
||||
environment:
|
||||
TRUST_PROXY_HEADER: 1
|
||||
DB_URL: postgres://postgres:p0stgr3s@localhost:5432/logto
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2000M
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- "-c"
|
||||
- "npm run cli db seed -- --swe && ENDPOINT=$$UFFIZZI_URL npm start"
|
||||
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
user: postgres
|
||||
environment:
|
||||
POSTGRES_USER: "postgres"
|
||||
POSTGRES_PASSWORD: "p0stgr3s"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500M
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
require('../lib/index.js');
|
2
packages/cli/bin/logto.js
Executable file
2
packages/cli/bin/logto.js
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env node
|
||||
import '../lib/index.js';
|
|
@ -1,7 +1,16 @@
|
|||
import { merge, Config } from '@silverhand/jest-config';
|
||||
import type { Config } from '@silverhand/jest-config';
|
||||
import { merge } from '@silverhand/jest-config';
|
||||
|
||||
const config: Config.InitialOptions = merge({
|
||||
roots: ['./src'],
|
||||
});
|
||||
const config: Config.InitialOptions = {
|
||||
...merge({
|
||||
setupFilesAfterEnv: ['./jest.setup.ts'],
|
||||
roots: ['./src'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
}),
|
||||
// Will update common config soon
|
||||
transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto))/)'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
19
packages/cli/jest.setup.ts
Normal file
19
packages/cli/jest.setup.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Mocking `import.meta.url` and `got` here since they inevitably needs native ESM, but jest is sticking with CJS.
|
||||
* Will figure out a way to run tests in native ESM mode.
|
||||
*/
|
||||
|
||||
jest.mock('./src/commands/database/alteration/meta-url.js', () => ({
|
||||
metaUrl: 'file:///',
|
||||
}));
|
||||
|
||||
jest.mock('./src/meta-url.js', () => ({
|
||||
metaUrl: 'file:///',
|
||||
}));
|
||||
|
||||
jest.mock('got', () => ({
|
||||
got: {},
|
||||
}));
|
||||
|
||||
// Make lint-staged happy
|
||||
export {};
|
|
@ -5,12 +5,13 @@
|
|||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"homepage": "https://github.com/logto-io/logto#readme",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"bin": {
|
||||
"logto": "bin/logto"
|
||||
"logto": "bin/logto.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
|
@ -47,14 +48,14 @@
|
|||
"decamelize": "^5.0.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"got": "^11.8.5",
|
||||
"hpagent": "^1.0.0",
|
||||
"got": "^12.5.3",
|
||||
"hpagent": "^1.2.0",
|
||||
"inquirer": "^8.2.2",
|
||||
"nanoid": "^3.3.4",
|
||||
"ora": "^5.0.0",
|
||||
"p-retry": "^4.6.1",
|
||||
"roarr": "^7.11.0",
|
||||
"semver": "^7.3.7",
|
||||
"semver": "^7.3.8",
|
||||
"slonik": "^30.0.0",
|
||||
"slonik-interceptor-preset": "^1.2.10",
|
||||
"slonik-sql-tag-raw": "^1.1.4",
|
||||
|
@ -74,7 +75,7 @@
|
|||
"@types/tar": "^6.1.2",
|
||||
"@types/yargs": "^17.0.13",
|
||||
"eslint": "^8.21.0",
|
||||
"jest": "^29.1.2",
|
||||
"jest": "^29.3.1",
|
||||
"lint-staged": "^13.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"rimraf": "^3.0.2",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { log } from '../../utilities';
|
||||
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils';
|
||||
import { log } from '../../utilities.js';
|
||||
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils.js';
|
||||
|
||||
const add: CommandModule<
|
||||
{ path?: string },
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import add from './add';
|
||||
import list from './list';
|
||||
import remove from './remove';
|
||||
import add from './add.js';
|
||||
import list from './list.js';
|
||||
import remove from './remove.js';
|
||||
|
||||
const connector: CommandModule = {
|
||||
command: ['connector', 'c', 'connectors'],
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { getConnectorPackagesFrom, isOfficialConnector } from './utils';
|
||||
import { getConnectorPackagesFrom, isOfficialConnector } from './utils.js';
|
||||
|
||||
const logConnectorNames = (type: string, names: string[]) => {
|
||||
if (names.length === 0) {
|
||||
|
|
|
@ -2,8 +2,8 @@ import chalk from 'chalk';
|
|||
import fsExtra from 'fs-extra';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { log } from '../../utilities';
|
||||
import { getConnectorPackagesFrom } from './utils';
|
||||
import { log } from '../../utilities.js';
|
||||
import { getConnectorPackagesFrom } from './utils.js';
|
||||
|
||||
const remove: CommandModule<{ path?: string }, { path?: string; packages?: string[] }> = {
|
||||
command: ['remove [packages...]', 'rm', 'delete'],
|
||||
|
|
|
@ -6,15 +6,15 @@ import { promisify } from 'util';
|
|||
|
||||
import { assert, conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { ensureDir, remove } from 'fs-extra';
|
||||
import fsExtra from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import pRetry from 'p-retry';
|
||||
import tar from 'tar';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { connectorDirectory } from '../../constants';
|
||||
import { getConnectorPackagesFromDirectory, isTty, log, oraPromise } from '../../utilities';
|
||||
import { defaultPath } from '../install/utils';
|
||||
import { connectorDirectory } from '../../constants.js';
|
||||
import { getConnectorPackagesFromDirectory, isTty, log, oraPromise } from '../../utilities.js';
|
||||
import { defaultPath } from '../install/utils.js';
|
||||
|
||||
const coreDirectory = 'packages/core';
|
||||
const execPromise = promisify(exec);
|
||||
|
@ -136,8 +136,8 @@ export const addConnectors = async (instancePath: string, packageNames: string[]
|
|||
const tarPath = path.join(cwd, escapedFilename);
|
||||
const packageDirectory = path.join(cwd, name.replace(/\//g, '-'));
|
||||
|
||||
await remove(packageDirectory);
|
||||
await ensureDir(packageDirectory);
|
||||
await fsExtra.remove(packageDirectory);
|
||||
await fsExtra.ensureDir(packageDirectory);
|
||||
await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 });
|
||||
await unlink(tarPath);
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { createMockPool } from 'slonik';
|
||||
|
||||
import * as functions from '.';
|
||||
import * as queries from '../../../queries/logto-config';
|
||||
import type { QueryType } from '../../../test-utilities';
|
||||
import { chooseAlterationsByVersion } from './version';
|
||||
import * as queries from '../../../queries/logto-config.js';
|
||||
import type { QueryType } from '../../../test-utilities.js';
|
||||
import * as functions from './index.js';
|
||||
import { chooseAlterationsByVersion } from './version.js';
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
|
|
|
@ -1,22 +1,26 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'path';
|
||||
|
||||
import type { AlterationScript } from '@logto/schemas/lib/types/alteration';
|
||||
import type { AlterationScript } from '@logto/schemas/lib/types/alteration.js';
|
||||
import { findPackage } from '@logto/shared';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { copy, existsSync, remove, readdir } from 'fs-extra';
|
||||
import fsExtra from 'fs-extra';
|
||||
import type { DatabasePool } from 'slonik';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { createPoolFromConfig } from '../../../database';
|
||||
import { createPoolFromConfig } from '../../../database.js';
|
||||
import {
|
||||
getCurrentDatabaseAlterationTimestamp,
|
||||
updateDatabaseTimestamp,
|
||||
} from '../../../queries/logto-config';
|
||||
import { getPathInModule, log } from '../../../utilities';
|
||||
import type { AlterationFile } from './type';
|
||||
import { chooseAlterationsByVersion } from './version';
|
||||
} from '../../../queries/logto-config.js';
|
||||
import { getPathInModule, log } from '../../../utilities.js';
|
||||
import { metaUrl } from './meta-url.js';
|
||||
import type { AlterationFile } from './type.js';
|
||||
import { chooseAlterationsByVersion } from './version.js';
|
||||
|
||||
const currentDirname = path.dirname(fileURLToPath(metaUrl));
|
||||
const { copy, existsSync, remove, readdir } = fsExtra;
|
||||
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
|
||||
|
||||
const getTimestampFromFilename = (filename: string) => {
|
||||
|
@ -45,16 +49,10 @@ export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
|
|||
* since they need a proper context that includes required dependencies (such as slonik) in `node_modules/`.
|
||||
* While the original `@logto/schemas` may remove them in production.
|
||||
*/
|
||||
const packageDirectory = await findPackage(
|
||||
// Until we migrate to ESM
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
__dirname
|
||||
);
|
||||
const packageDirectory = await findPackage(currentDirname);
|
||||
|
||||
const localAlterationDirectory = path.resolve(
|
||||
// Until we migrate to ESM
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
packageDirectory ?? __dirname,
|
||||
packageDirectory ?? currentDirname,
|
||||
'alteration-scripts'
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
// Have to define this in a separate file since Jest sticks with CJS
|
||||
// We need to mock this before running tests
|
||||
// https://github.com/facebook/jest/issues/12952
|
||||
export const metaUrl = import.meta.url;
|
|
@ -3,8 +3,8 @@ import chalk from 'chalk';
|
|||
import inquirer from 'inquirer';
|
||||
import { SemVer, compare, eq, gt } from 'semver';
|
||||
|
||||
import { findLastIndex, isTty, log } from '../../../utilities';
|
||||
import type { AlterationFile } from './type';
|
||||
import { findLastIndex, isTty, log } from '../../../utilities.js';
|
||||
import type { AlterationFile } from './type.js';
|
||||
|
||||
const getVersionFromFilename = (filename: string) => {
|
||||
try {
|
||||
|
|
|
@ -4,10 +4,10 @@ import { deduplicate, noop } from '@silverhand/essentials';
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { createPoolFromConfig } from '../../database';
|
||||
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config';
|
||||
import { log } from '../../utilities';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities';
|
||||
import { createPoolFromConfig } from '../../database.js';
|
||||
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js';
|
||||
import { log } from '../../utilities.js';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities.js';
|
||||
|
||||
const validKeysDisplay = chalk.green(logtoConfigKeys.join(', '));
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import alteration from './alteration';
|
||||
import config from './config';
|
||||
import seed from './seed';
|
||||
import alteration from './alteration/index.js';
|
||||
import config from './config.js';
|
||||
import seed from './seed/index.js';
|
||||
|
||||
const database: CommandModule = {
|
||||
command: ['database', 'db'],
|
||||
|
|
|
@ -10,16 +10,16 @@ import { raw } from 'slonik-sql-tag-raw';
|
|||
import type { CommandModule } from 'yargs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database';
|
||||
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database.js';
|
||||
import {
|
||||
getRowsByKeys,
|
||||
doesConfigsTableExist,
|
||||
updateDatabaseTimestamp,
|
||||
updateValueByKey,
|
||||
} from '../../../queries/logto-config';
|
||||
import { getPathInModule, log, oraPromise } from '../../../utilities';
|
||||
import { getLatestAlterationTimestamp } from '../alteration';
|
||||
import { oidcConfigReaders } from './oidc-config';
|
||||
} from '../../../queries/logto-config.js';
|
||||
import { getPathInModule, log, oraPromise } from '../../../utilities.js';
|
||||
import { getLatestAlterationTimestamp } from '../alteration/index.js';
|
||||
import { oidcConfigReaders } from './oidc-config.js';
|
||||
|
||||
const createTables = async (connection: DatabaseTransactionConnection) => {
|
||||
const tableDirectory = getPathInModule('@logto/schemas', 'tables');
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { LogtoOidcConfigType } from '@logto/schemas';
|
|||
import { LogtoOidcConfigKey } from '@logto/schemas';
|
||||
import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
||||
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js';
|
||||
|
||||
const isBase64FormatPrivateKey = (key: string) => !key.includes('-');
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { getDatabaseUrlFromConfig } from '../../database';
|
||||
import { log } from '../../utilities';
|
||||
import { addOfficialConnectors } from '../connector/utils';
|
||||
import { getDatabaseUrlFromConfig } from '../../database.js';
|
||||
import { log } from '../../utilities.js';
|
||||
import { addOfficialConnectors } from '../connector/utils.js';
|
||||
import {
|
||||
validateNodeVersion,
|
||||
inquireInstancePath,
|
||||
|
@ -15,7 +15,7 @@ import {
|
|||
decompress,
|
||||
inquireOfficialConnectors,
|
||||
isUrl,
|
||||
} from './utils';
|
||||
} from './utils.js';
|
||||
|
||||
export type InstallArgs = {
|
||||
path?: string;
|
||||
|
|
|
@ -6,12 +6,12 @@ import path from 'path';
|
|||
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { remove, writeFile } from 'fs-extra';
|
||||
import fsExtra from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import * as semver from 'semver';
|
||||
import tar from 'tar';
|
||||
|
||||
import { createPoolAndDatabaseIfNeeded } from '../../database';
|
||||
import { createPoolAndDatabaseIfNeeded } from '../../database.js';
|
||||
import {
|
||||
cliConfig,
|
||||
ConfigKey,
|
||||
|
@ -20,8 +20,8 @@ import {
|
|||
log,
|
||||
oraPromise,
|
||||
safeExecSync,
|
||||
} from '../../utilities';
|
||||
import { seedByPool } from '../database/seed';
|
||||
} from '../../utilities.js';
|
||||
import { seedByPool } from '../database/seed/index.js';
|
||||
|
||||
export const defaultPath = path.join(os.homedir(), 'logto');
|
||||
const pgRequired = new semver.SemVer('14.0.0');
|
||||
|
@ -140,7 +140,7 @@ export const seedDatabase = async (instancePath: string) => {
|
|||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
await oraPromise(remove(instancePath), {
|
||||
await oraPromise(fsExtra.remove(instancePath), {
|
||||
text: 'Clean up',
|
||||
prefixText: chalk.blue('[info]'),
|
||||
});
|
||||
|
@ -156,7 +156,7 @@ export const seedDatabase = async (instancePath: string) => {
|
|||
|
||||
export const createEnv = async (instancePath: string, databaseUrl: string) => {
|
||||
const dotEnvPath = path.resolve(instancePath, '.env');
|
||||
await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, {
|
||||
await fsExtra.writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`);
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createPool, parseDsn, sql, stringifyDsn } from 'slonik';
|
|||
import { createInterceptors } from 'slonik-interceptor-preset';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ConfigKey, getCliConfigWithPrompt, log } from './utilities';
|
||||
import { ConfigKey, getCliConfigWithPrompt, log } from './utilities.js';
|
||||
|
||||
export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto';
|
||||
|
||||
|
|
|
@ -3,11 +3,11 @@ import dotenv from 'dotenv';
|
|||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
import connector from './commands/connector';
|
||||
import database from './commands/database';
|
||||
import install from './commands/install';
|
||||
import { packageJson } from './package-json';
|
||||
import { cliConfig, ConfigKey } from './utilities';
|
||||
import connector from './commands/connector/index.js';
|
||||
import database from './commands/database/index.js';
|
||||
import install from './commands/install/index.js';
|
||||
import { packageJson } from './package-json.js';
|
||||
import { cliConfig, ConfigKey } from './utilities.js';
|
||||
|
||||
void yargs(hideBin(process.argv))
|
||||
.version(false)
|
||||
|
|
4
packages/cli/src/meta-url.ts
Normal file
4
packages/cli/src/meta-url.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
// Have to define this in a separate file since Jest sticks with CJS
|
||||
// We need to mock this before running tests
|
||||
// https://github.com/facebook/jest/issues/12952
|
||||
export const metaUrl = import.meta.url;
|
|
@ -2,9 +2,9 @@ import { AlterationStateKey, LogtoConfigs } from '@logto/schemas';
|
|||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import type { QueryType } from '../test-utilities';
|
||||
import { expectSqlAssert } from '../test-utilities';
|
||||
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config';
|
||||
import type { QueryType } from '../test-utilities.js';
|
||||
import { expectSqlAssert } from '../test-utilities.js';
|
||||
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config.js';
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copied from core
|
||||
|
||||
import type { QueryResult, QueryResultRow } from 'slonik';
|
||||
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.d';
|
||||
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.js';
|
||||
|
||||
export type QueryType = (
|
||||
sql: string,
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
import { execSync } from 'child_process';
|
||||
import { createWriteStream, existsSync } from 'fs';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import type { Progress } from 'got';
|
||||
import got from 'got';
|
||||
import { got } from 'got';
|
||||
import { HttpsProxyAgent } from 'hpagent';
|
||||
import inquirer from 'inquirer';
|
||||
import ora from 'ora';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { metaUrl } from './meta-url.js';
|
||||
|
||||
export const safeExecSync = (command: string) => {
|
||||
try {
|
||||
return execSync(command, { encoding: 'utf8', stdio: 'pipe' });
|
||||
|
@ -83,9 +86,7 @@ export const downloadFile = async (url: string, destination: string) => {
|
|||
export const getPathInModule = (moduleName: string, relativePath = '/') =>
|
||||
// https://stackoverflow.com/a/49455609/12514940
|
||||
path.join(
|
||||
// Until we migrate to ESM
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
path.dirname(require.resolve(`${moduleName}/package.json`)),
|
||||
path.dirname(createRequire(metaUrl).resolve(`${moduleName}/package.json`)),
|
||||
relativePath
|
||||
);
|
||||
|
||||
|
|
|
@ -3,13 +3,14 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"declaration": true,
|
||||
"module": "node16",
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "es2022",
|
||||
"target": "es2022",
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"jest.config.ts"
|
||||
"jest.*.ts"
|
||||
],
|
||||
"exclude": ["**/alteration-scripts"]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"allowJs": true
|
||||
}
|
||||
}
|
||||
|
|
13
packages/console/.svgorc.json
Normal file
13
packages/console/.svgorc.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "preset-default",
|
||||
"params": {
|
||||
"overrides": {
|
||||
"cleanupIDs": false,
|
||||
"removeViewBox": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"homepage": "https://github.com/logto-io/logto#readme",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"precommit": "lint-staged",
|
||||
|
@ -18,8 +19,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/roboto-mono": "^4.5.7",
|
||||
"@logto/core-kit": "1.0.0-beta.20",
|
||||
"@logto/language-kit": "1.0.0-beta.20",
|
||||
"@logto/core-kit": "1.0.0-beta.25",
|
||||
"@logto/language-kit": "1.0.0-beta.24",
|
||||
"@logto/phrases": "workspace:^",
|
||||
"@logto/phrases-ui": "workspace:^",
|
||||
"@logto/react": "1.0.0-beta.13",
|
||||
|
@ -48,16 +49,16 @@
|
|||
"clean-deep": "^3.4.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.0.11",
|
||||
"date-fns": "^2.29.3",
|
||||
"dayjs": "^1.10.5",
|
||||
"deep-object-diff": "^1.1.7",
|
||||
"date-fns": "^2.29.3",
|
||||
"deepmerge": "^4.2.2",
|
||||
"dnd-core": "^16.0.0",
|
||||
"eslint": "^8.21.0",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.8.16",
|
||||
"i18next-browser-languagedetector": "^6.1.4",
|
||||
"ky": "^0.31.0",
|
||||
"ky": "^0.32.0",
|
||||
"lint-staged": "^13.0.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
|
|
|
@ -55,7 +55,6 @@ const Main = () => {
|
|||
<Route path=":id">
|
||||
<Route index element={<Navigate replace to="settings" />} />
|
||||
<Route path="settings" element={<ApplicationDetails />} />
|
||||
<Route path="advanced-settings" element={<ApplicationDetails />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="api-resources">
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
|
||||
<path d="M35.0894 14.0219C33.0498 13.086 30.8626 12.3966 28.5759 12.0017C28.5342 11.994 28.4926 12.0131 28.4712 12.0512C28.1899 12.5515 27.8783 13.2041 27.6601 13.7171C25.2005 13.3489 22.7536 13.3489 20.3444 13.7171C20.1262 13.1927 19.8033 12.5515 19.5208 12.0512C19.4993 12.0144 19.4577 11.9953 19.4161 12.0017C17.1305 12.3953 14.9434 13.0848 12.9026 14.0219C12.8849 14.0295 12.8698 14.0422 12.8597 14.0587C8.71119 20.2565 7.57473 26.302 8.13224 32.2725C8.13476 32.3017 8.15116 32.3297 8.17386 32.3474C10.9109 34.3575 13.5623 35.5778 16.1644 36.3866C16.206 36.3993 16.2501 36.3841 16.2766 36.3498C16.8922 35.5092 17.4409 34.6229 17.9113 33.6908C17.9391 33.6363 17.9126 33.5715 17.8558 33.5499C16.9855 33.2198 16.1568 32.8172 15.3596 32.3601C15.2966 32.3233 15.2915 32.2331 15.3495 32.19C15.5173 32.0643 15.6851 31.9335 15.8453 31.8014C15.8743 31.7773 15.9146 31.7722 15.9487 31.7874C21.1857 34.1785 26.8554 34.1785 32.0306 31.7874C32.0647 31.7709 32.1051 31.776 32.1353 31.8001C32.2955 31.9322 32.4633 32.0643 32.6323 32.19C32.6903 32.2331 32.6865 32.3233 32.6235 32.3601C31.8263 32.8261 30.9976 33.2198 30.126 33.5486C30.0693 33.5702 30.044 33.6363 30.0718 33.6908C30.5523 34.6216 31.101 35.5079 31.7052 36.3485C31.7304 36.3841 31.7758 36.3993 31.8175 36.3866C34.4322 35.5778 37.0835 34.3575 39.8206 32.3474C39.8446 32.3297 39.8597 32.303 39.8622 32.2738C40.5294 25.3712 38.7447 19.3753 35.131 14.06C35.1221 14.0422 35.107 14.0295 35.0894 14.0219ZM18.6934 28.6371C17.1167 28.6371 15.8175 27.1896 15.8175 25.4119C15.8175 23.6341 17.0915 22.1866 18.6934 22.1866C20.3078 22.1866 21.5944 23.6469 21.5692 25.4119C21.5692 27.1896 20.2952 28.6371 18.6934 28.6371ZM29.3263 28.6371C27.7497 28.6371 26.4505 27.1896 26.4505 25.4119C26.4505 23.6341 27.7244 22.1866 29.3263 22.1866C30.9408 22.1866 32.2274 23.6469 32.2022 25.4119C32.2022 27.1896 30.9408 28.6371 29.3263 28.6371Z" fill="#A5ABF0"/>
|
||||
<rect width="48" height="48" rx="12" fill="#191C1D"/>
|
||||
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
|
||||
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
|
||||
<path d="M35.0894 14.0219C33.0498 13.086 30.8626 12.3966 28.5759 12.0017C28.5342 11.994 28.4926 12.0131 28.4712 12.0512C28.1899 12.5515 27.8783 13.2041 27.6601 13.7171C25.2005 13.3489 22.7536 13.3489 20.3444 13.7171C20.1262 13.1927 19.8033 12.5515 19.5208 12.0512C19.4993 12.0144 19.4577 11.9953 19.4161 12.0017C17.1305 12.3953 14.9434 13.0848 12.9026 14.0219C12.8849 14.0295 12.8698 14.0422 12.8597 14.0587C8.71119 20.2565 7.57473 26.302 8.13224 32.2725C8.13476 32.3017 8.15116 32.3297 8.17386 32.3474C10.9109 34.3575 13.5623 35.5778 16.1644 36.3866C16.206 36.3993 16.2501 36.3841 16.2766 36.3498C16.8922 35.5092 17.4409 34.6229 17.9113 33.6908C17.9391 33.6363 17.9126 33.5715 17.8558 33.5499C16.9855 33.2198 16.1568 32.8172 15.3596 32.3601C15.2966 32.3233 15.2915 32.2331 15.3495 32.19C15.5173 32.0643 15.6851 31.9335 15.8453 31.8014C15.8743 31.7773 15.9146 31.7722 15.9487 31.7874C21.1857 34.1785 26.8554 34.1785 32.0306 31.7874C32.0647 31.7709 32.1051 31.776 32.1353 31.8001C32.2955 31.9322 32.4633 32.0643 32.6323 32.19C32.6903 32.2331 32.6865 32.3233 32.6235 32.3601C31.8263 32.8261 30.9976 33.2198 30.126 33.5486C30.0693 33.5702 30.044 33.6363 30.0718 33.6908C30.5523 34.6216 31.101 35.5079 31.7052 36.3485C31.7304 36.3841 31.7758 36.3993 31.8175 36.3866C34.4322 35.5778 37.0835 34.3575 39.8206 32.3474C39.8446 32.3297 39.8597 32.303 39.8622 32.2738C40.5294 25.3712 38.7447 19.3753 35.131 14.06C35.1221 14.0422 35.107 14.0295 35.0894 14.0219ZM18.6934 28.6371C17.1167 28.6371 15.8175 27.1896 15.8175 25.4119C15.8175 23.6341 17.0915 22.1866 18.6934 22.1866C20.3078 22.1866 21.5944 23.6469 21.5692 25.4119C21.5692 27.1896 20.2952 28.6371 18.6934 28.6371ZM29.3263 28.6371C27.7497 28.6371 26.4505 27.1896 26.4505 25.4119C26.4505 23.6341 27.7244 22.1866 29.3263 22.1866C30.9408 22.1866 32.2274 23.6469 32.2022 25.4119C32.2022 27.1896 30.9408 28.6371 29.3263 28.6371Z" fill="#5865F2"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
@ -1,4 +1,6 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
|
||||
<rect width="48" height="48" rx="12" fill="#191C1D"/>
|
||||
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
|
||||
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 40C32.8366 40 40 32.8366 40 24C40 15.1634 32.8366 8 24 8C15.1634 8 8 15.1634 8 24C8 32.8366 15.1634 40 24 40ZM30 16C30.4747 16 30.9301 16.0827 31.3526 16.2345L24.001 23.5861L16.6488 16.234C17.0709 16.0825 17.5258 16 18 16H30ZM24.7586 25.6569C24.5505 25.865 24.2735 25.9622 24.001 25.9485C23.7285 25.9622 23.4514 25.865 23.2433 25.6569L14.9723 17.3859C14.3665 18.087 14 19.0007 14 20V28C14 30.2091 15.7909 32 18 32H30C32.2091 32 34 30.2091 34 28V20C34 19.0012 33.6339 18.0879 33.0286 17.3869L24.7586 25.6569Z" fill="#8ECF8E"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 834 B After Width: | Height: | Size: 884 B |
|
@ -1,4 +1,6 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
|
||||
<path d="M23.9999 8C20.2007 8.00016 16.5255 9.38638 13.6319 11.9106C10.7384 14.4349 8.81524 17.9324 8.20666 21.7774C7.59807 25.6225 8.34375 29.5641 10.3103 32.897C12.2768 36.2299 15.3358 38.7366 18.94 39.9686C19.74 40.1122 20.04 39.62 20.04 39.1894C20.04 38.7998 20.02 37.5079 20.02 36.134C16 36.8927 14.96 35.1293 14.64 34.2065C14.2849 33.3091 13.722 32.5138 13 31.8893C12.44 31.5817 11.64 30.823 12.98 30.8025C13.4916 30.8595 13.9824 31.042 14.4106 31.3347C14.8388 31.6274 15.1919 32.0216 15.44 32.484C15.6588 32.887 15.9531 33.2419 16.3059 33.5281C16.6587 33.8144 17.0631 34.0264 17.496 34.1522C17.9289 34.2779 18.3817 34.3148 18.8286 34.2608C19.2754 34.2068 19.7074 34.0629 20.0999 33.8373C20.1692 33.0034 20.5317 32.2236 21.12 31.6433C17.56 31.2332 13.84 29.8182 13.84 23.5435C13.8175 21.9131 14.4043 20.3357 15.48 19.1347C14.9908 17.7177 15.0481 16.1627 15.64 14.7875C15.64 14.7875 16.9799 14.3568 20.04 16.469C22.658 15.7307 25.4219 15.7307 28.0399 16.469C31.0998 14.3364 32.4399 14.7875 32.4399 14.7875C33.0319 16.1626 33.0891 17.7177 32.5999 19.1347C33.6788 20.3336 34.2661 21.9124 34.2399 23.5435C34.2399 29.8387 30.4999 31.2332 26.9399 31.6433C27.3217 32.0401 27.6158 32.5165 27.8022 33.0402C27.9885 33.5638 28.0628 34.1225 28.0199 34.6782C28.0199 36.8723 27.9999 38.6357 27.9999 39.1894C27.9999 39.6201 28.2999 40.1327 29.0999 39.9687C32.6977 38.7266 35.748 36.214 37.7061 32.8794C39.6642 29.5448 40.4028 25.6052 39.79 21.7639C39.1772 17.9226 37.2529 14.4296 34.3606 11.9084C31.4683 9.38726 27.7962 8.00203 23.9999 8Z" fill="#B3A6DA"/>
|
||||
<rect width="48" height="48" rx="12" fill="#191C1D"/>
|
||||
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
|
||||
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
|
||||
<path d="M23.9999 8C20.2007 8.00016 16.5255 9.38638 13.6319 11.9106C10.7384 14.4349 8.81524 17.9324 8.20666 21.7774C7.59807 25.6225 8.34375 29.5641 10.3103 32.897C12.2768 36.2299 15.3358 38.7366 18.94 39.9686C19.74 40.1122 20.04 39.62 20.04 39.1894C20.04 38.7998 20.02 37.5079 20.02 36.134C16 36.8927 14.96 35.1293 14.64 34.2065C14.2849 33.3091 13.722 32.5138 13 31.8893C12.44 31.5817 11.64 30.823 12.98 30.8025C13.4916 30.8595 13.9824 31.042 14.4106 31.3347C14.8388 31.6274 15.1919 32.0216 15.44 32.484C15.6588 32.887 15.9531 33.2419 16.3059 33.5281C16.6587 33.8144 17.0631 34.0264 17.496 34.1522C17.9289 34.2779 18.3817 34.3148 18.8286 34.2608C19.2754 34.2068 19.7074 34.0629 20.0999 33.8373C20.1692 33.0034 20.5317 32.2236 21.12 31.6433C17.56 31.2332 13.84 29.8182 13.84 23.5435C13.8175 21.9131 14.4043 20.3357 15.48 19.1347C14.9908 17.7177 15.0481 16.1627 15.64 14.7875C15.64 14.7875 16.9799 14.3568 20.04 16.469C22.658 15.7307 25.4219 15.7307 28.0399 16.469C31.0998 14.3364 32.4399 14.7875 32.4399 14.7875C33.0319 16.1626 33.0891 17.7177 32.5999 19.1347C33.6788 20.3336 34.2661 21.9124 34.2399 23.5435C34.2399 29.8387 30.4999 31.2332 26.9399 31.6433C27.3217 32.0401 27.6158 32.5165 27.8022 33.0402C27.9885 33.5638 28.0628 34.1225 28.0199 34.6782C28.0199 36.8723 27.9999 38.6357 27.9999 39.1894C27.9999 39.6201 28.2999 40.1327 29.0999 39.9687C32.6977 38.7266 35.748 36.214 37.7061 32.8794C39.6642 29.5448 40.4028 25.6052 39.79 21.7639C39.1772 17.9226 37.2529 14.4296 34.3606 11.9084C31.4683 9.38726 27.7962 8.00203 23.9999 8Z" fill="white"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
@ -7,6 +7,7 @@
|
|||
flex-shrink: 0;
|
||||
width: 248px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
> div + div {
|
||||
margin-top: _.unit(6);
|
||||
|
|
|
@ -41,12 +41,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
padding: _.unit(2);
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
min-width: 170px;
|
||||
padding: _.unit(2.5) _.unit(2);
|
||||
|
||||
&.loading {
|
||||
opacity: 60%;
|
||||
|
|
|
@ -26,11 +26,7 @@ const UserInfo = () => {
|
|||
(async () => {
|
||||
if (isAuthenticated) {
|
||||
const userInfo = await getIdTokenClaims();
|
||||
// TODO: revert after SDK updated
|
||||
setUser({
|
||||
picture: undefined,
|
||||
...(userInfo ?? { sub: '', username: 'N/A' }),
|
||||
}); // Provide a fallback to avoid infinite loading state
|
||||
setUser(userInfo ?? { sub: '', username: 'N/A' }); // Provide a fallback to avoid infinite loading state
|
||||
}
|
||||
})();
|
||||
}, [isAuthenticated, getIdTokenClaims]);
|
||||
|
@ -55,15 +51,13 @@ const UserInfo = () => {
|
|||
setShowDropdown(true);
|
||||
}}
|
||||
>
|
||||
{/* TODO: revert after SDK updated */}
|
||||
<img src={picture ? String(picture) : generateAvatarPlaceHolderById(id)} alt="avatar" />
|
||||
<img src={picture ?? generateAvatarPlaceHolderById(id)} alt="avatar" />
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.name}>{username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
anchorRef={anchorRef}
|
||||
className={styles.dropdown}
|
||||
isOpen={showDropdown}
|
||||
horizontalAlign="end"
|
||||
onClose={() => {
|
||||
|
|
|
@ -15,13 +15,12 @@
|
|||
.content {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
margin-bottom: _.unit(6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding-right: _.unit(6);
|
||||
padding: 0 _.unit(2);
|
||||
overflow-y: scroll;
|
||||
|
||||
> * {
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: _.unit(3);
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
background-color: var(--color-layer-1);
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
.title {
|
||||
color: var(--color-text-secondary);
|
||||
|
@ -21,14 +31,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
.tableLayout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
.tableContainer {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: _.unit(4);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.eventName {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { LogDto } from '@logto/schemas';
|
||||
import { LogResult } from '@logto/schemas';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
@ -26,6 +26,8 @@ type Props = {
|
|||
userId?: string;
|
||||
};
|
||||
|
||||
const defaultTableColumn = 4;
|
||||
|
||||
const AuditLogTable = ({ userId }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { pathname } = useLocation();
|
||||
|
@ -49,6 +51,7 @@ const AuditLogTable = ({ userId }: Props) => {
|
|||
const navigate = useNavigate();
|
||||
const [logs, totalCount] = data ?? [];
|
||||
const showUserColumn = !userId;
|
||||
const tableColumnCount = showUserColumn ? defaultTableColumn : defaultTableColumn - 1;
|
||||
|
||||
const updateQuery = (key: string, value: string) => {
|
||||
const queries: Record<string, string> = {};
|
||||
|
@ -65,91 +68,91 @@ const AuditLogTable = ({ userId }: Props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.title}>{t('logs.filter_by')}</div>
|
||||
<div className={styles.eventSelector}>
|
||||
<EventSelector
|
||||
value={event ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('event', value ?? '');
|
||||
}}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.tableLayout}>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.title}>{t('logs.filter_by')}</div>
|
||||
<div className={styles.eventSelector}>
|
||||
<EventSelector
|
||||
value={event ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('event', value ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.applicationSelector}>
|
||||
<ApplicationSelector
|
||||
value={applicationId ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('applicationId', value ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.applicationSelector}>
|
||||
<ApplicationSelector
|
||||
value={applicationId ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('applicationId', value ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table className={classNames(logs?.length === 0 && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.eventName} />
|
||||
{showUserColumn && <col />}
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event')}</th>
|
||||
{showUserColumn && <th>{t('logs.user')}</th>}
|
||||
<th>{t('logs.application')}</th>
|
||||
<th>{t('logs.time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={4}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={4} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={4} />}
|
||||
{logs?.map(({ type, payload, createdAt, id }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`${pathname}/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
|
||||
</td>
|
||||
{showUserColumn && (
|
||||
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
|
||||
)}
|
||||
<td>
|
||||
{payload.applicationId ? (
|
||||
<ApplicationName applicationId={payload.applicationId} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||
<div className={classNames(tableStyles.scrollable, styles.tableContainer)}>
|
||||
<table className={conditional(logs?.length === 0 && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.eventName} />
|
||||
{showUserColumn && <col />}
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event')}</th>
|
||||
{showUserColumn && <th>{t('logs.user')}</th>}
|
||||
<th>{t('logs.application')}</th>
|
||||
<th>{t('logs.time')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={tableColumnCount}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={tableColumnCount} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={tableColumnCount} />}
|
||||
{logs?.map(({ type, payload, createdAt, id }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`${pathname}/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
|
||||
</td>
|
||||
{showUserColumn && (
|
||||
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
|
||||
)}
|
||||
<td>
|
||||
{payload.applicationId ? (
|
||||
<ApplicationName applicationId={payload.applicationId} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagination}>
|
||||
{!!totalCount && (
|
||||
<Pagination
|
||||
pageCount={Math.ceil(totalCount / pageSize)}
|
||||
pageIndex={pageIndex}
|
||||
onChange={(page) => {
|
||||
updateQuery('page', String(page));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Pagination
|
||||
pageIndex={pageIndex}
|
||||
totalCount={totalCount ?? 0}
|
||||
pageSize={pageSize}
|
||||
className={styles.pagination}
|
||||
onChange={(page) => {
|
||||
updateQuery('page', String(page));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,16 +10,17 @@ type Props = {
|
|||
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
|
||||
subtitle?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Always use this component to render CardTitle, with built-in i18n support.
|
||||
*/
|
||||
const CardTitle = ({ title, subtitle, size = 'large' }: Props) => {
|
||||
const CardTitle = ({ title, subtitle, size = 'large', className }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.container, styles[size])}>
|
||||
<div className={classNames(styles.container, styles[size], className)}>
|
||||
<div className={styles.title}>{typeof title === 'string' ? t(title) : title}</div>
|
||||
{subtitle && (
|
||||
<div className={styles.subtitle}>
|
||||
|
|
|
@ -47,7 +47,7 @@ const ConfirmModal = ({
|
|||
title={title}
|
||||
footer={
|
||||
<>
|
||||
<Button type="outline" title={cancelButtonText} onClick={onCancel} />
|
||||
<Button title={cancelButtonText} onClick={onCancel} />
|
||||
<Button
|
||||
type={confirmButtonType}
|
||||
title={confirmButtonText}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
>:not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex-grow: 1;
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
}
|
||||
}
|
28
packages/console/src/components/DetailsForm/index.tsx
Normal file
28
packages/console/src/components/DetailsForm/index.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
import SubmitFormChangesActionBar from '../SubmitFormChangesActionBar';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
isDirty: boolean;
|
||||
isSubmitting: boolean;
|
||||
children: ReactNode;
|
||||
onSubmit: () => Promise<void>;
|
||||
onDiscard: () => void;
|
||||
};
|
||||
|
||||
const DetailsForm = ({ isDirty, isSubmitting, onSubmit, onDiscard, children }: Props) => {
|
||||
return (
|
||||
<form className={styles.container}>
|
||||
<div className={styles.fields}>{children}</div>
|
||||
<SubmitFormChangesActionBar
|
||||
isOpen={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={onSubmit}
|
||||
onDiscard={onDiscard}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailsForm;
|
48
packages/console/src/components/FormCard/index.module.scss
Normal file
48
packages/console/src/components/FormCard/index.module.scss
Normal file
|
@ -0,0 +1,48 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
padding: _.unit(6) _.unit(8);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.introduction {
|
||||
width: 296px;
|
||||
padding-bottom: _.unit(6);
|
||||
margin-right: _.unit(14);
|
||||
flex-shrink: 0;
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.title {
|
||||
@include _.subhead-cap;
|
||||
color: var(--color-neutral-variant-60);
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
a {
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
|
||||
.introduction {
|
||||
width: 100%;
|
||||
margin-right: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
flex-grow: 1;
|
||||
}
|
38
packages/console/src/components/FormCard/index.tsx
Normal file
38
packages/console/src/components/FormCard/index.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import type { AdminConsoleKey } from '@logto/phrases';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Card from '../Card';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: AdminConsoleKey;
|
||||
description?: AdminConsoleKey;
|
||||
learnMoreLink?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const FormCard = ({ title, description, learnMoreLink, children }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<Card className={styles.container}>
|
||||
<div className={styles.introduction}>
|
||||
<div className={styles.title}>{t(title)}</div>
|
||||
{description && (
|
||||
<div className={styles.description}>
|
||||
{t(description)}
|
||||
{learnMoreLink && (
|
||||
<a href={learnMoreLink} target="_blank" rel="noopener">
|
||||
{t('general.learn_more')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.form}>{children}</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormCard;
|
|
@ -11,21 +11,29 @@ import Spacer from '../Spacer';
|
|||
import Tooltip from '../Tooltip';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
|
||||
children: ReactNode;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
headlineClassName?: string;
|
||||
tooltip?: AdminConsoleKey;
|
||||
};
|
||||
|
||||
const FormField = ({ title, children, isRequired, className, tooltip }: Props) => {
|
||||
const FormField = ({
|
||||
title,
|
||||
children,
|
||||
isRequired,
|
||||
className,
|
||||
tooltip,
|
||||
headlineClassName,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.field, className)}>
|
||||
<div className={styles.headline}>
|
||||
<div className={classNames(styles.headline, headlineClassName)}>
|
||||
<div className={styles.title}>{typeof title === 'string' ? t(title) : title}</div>
|
||||
{tooltip && (
|
||||
<div ref={tipRef} className={styles.icon}>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
min-width: dim.$modal-layout-width-small;
|
||||
padding: _.unit(6);
|
||||
margin: 0 _.unit(6);
|
||||
border: 1px solid var(--color-divider);
|
||||
box-shadow: var(--shadow-3);
|
||||
|
||||
.header {
|
||||
|
|
|
@ -5,14 +5,20 @@
|
|||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.firstFieldWithMultiInputs {
|
||||
padding-right: _.unit(9);
|
||||
}
|
||||
|
||||
.deletableInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> :first-child {
|
||||
@include _.form-text-field;
|
||||
margin-right: _.unit(2);
|
||||
flex-shrink: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import type { AdminConsoleKey } from '@logto/phrases';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -12,16 +14,25 @@ import TextInput from '../TextInput';
|
|||
import * as styles from './index.module.scss';
|
||||
import type { MultiTextInputError } from './types';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
title: AdminConsoleKey;
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onKeyPress?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
error?: MultiTextInputError;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder }: Props) => {
|
||||
const MultiTextInput = ({
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
onKeyPress,
|
||||
error,
|
||||
placeholder,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const [deleteFieldIndex, setDeleteFieldIndex] = useState<number>();
|
||||
|
@ -47,10 +58,15 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={styles.multilineInput}>
|
||||
<div className={classNames(styles.multilineInput, className)}>
|
||||
{fields.map((fieldValue, fieldIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={fieldIndex}>
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={fieldIndex}
|
||||
className={conditional(
|
||||
fields.length > 1 && fieldIndex === 0 && styles.firstFieldWithMultiInputs
|
||||
)}
|
||||
>
|
||||
<div className={styles.deletableInput}>
|
||||
<TextInput
|
||||
hasError={Boolean(
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.headlineWithMultiInputs {
|
||||
padding-right: _.unit(9);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import type { Props as FormFieldProps } from '@/components/FormField';
|
||||
import FormField from '@/components/FormField';
|
||||
import type { Props as MultiTextInputProps } from '@/components/MultiTextInput';
|
||||
|
||||
import MultiTextInput from '../MultiTextInput';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = MultiTextInputProps &
|
||||
Pick<FormFieldProps, 'isRequired' | 'tooltip'> & {
|
||||
formFieldClassName?: FormFieldProps['className'];
|
||||
};
|
||||
|
||||
const MultiTextInputField = ({
|
||||
title,
|
||||
isRequired,
|
||||
tooltip,
|
||||
formFieldClassName,
|
||||
value,
|
||||
...rest
|
||||
}: Props) => (
|
||||
<FormField
|
||||
title={title}
|
||||
isRequired={isRequired}
|
||||
tooltip={tooltip}
|
||||
className={formFieldClassName}
|
||||
headlineClassName={conditional(value && value.length > 1 && styles.headlineWithMultiInputs)}
|
||||
>
|
||||
<MultiTextInput title={title} value={value} {...rest} />
|
||||
</FormField>
|
||||
);
|
||||
|
||||
export default MultiTextInputField;
|
|
@ -1,9 +1,22 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.positionInfo {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
margin: 0;
|
||||
height: 28px;
|
||||
padding-inline-start: _.unit(4);
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactPaginate from 'react-paginate';
|
||||
|
||||
import Button from '../Button';
|
||||
|
@ -9,34 +10,51 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Props = {
|
||||
pageIndex: number;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
className?: string;
|
||||
onChange?: (pageIndex: number) => void;
|
||||
};
|
||||
|
||||
const Pagination = ({ pageIndex, pageCount, onChange }: Props) => {
|
||||
const Pagination = ({ pageIndex, totalCount, pageSize, className, onChange }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const pageCount = Math.ceil(totalCount / pageSize);
|
||||
|
||||
if (pageCount <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const min = (pageIndex - 1) * pageSize + 1;
|
||||
const max = Math.min(pageIndex * pageSize, totalCount);
|
||||
|
||||
return (
|
||||
<ReactPaginate
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
forcePage={pageIndex - 1}
|
||||
pageLabelBuilder={(page: number) => (
|
||||
<Button
|
||||
type={page === pageIndex ? 'outline' : 'default'}
|
||||
className={classNames(styles.button, page === pageIndex && styles.active)}
|
||||
size="small"
|
||||
title={<DangerousRaw>{page}</DangerousRaw>}
|
||||
/>
|
||||
)}
|
||||
previousLabel={<Button className={styles.button} size="small" icon={<Previous />} />}
|
||||
nextLabel={<Button className={styles.button} size="small" icon={<Next />} />}
|
||||
breakLabel={
|
||||
<Button className={styles.button} size="small" title={<DangerousRaw>...</DangerousRaw>} />
|
||||
}
|
||||
disabledClassName={styles.disabled}
|
||||
onPageChange={({ selected }) => {
|
||||
onChange?.(selected + 1);
|
||||
}}
|
||||
/>
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.positionInfo}>
|
||||
{t('general.page_info', { min, max, total: totalCount })}
|
||||
</div>
|
||||
<ReactPaginate
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
forcePage={pageIndex - 1}
|
||||
pageLabelBuilder={(page: number) => (
|
||||
<Button
|
||||
type={page === pageIndex ? 'outline' : 'default'}
|
||||
className={classNames(styles.button, page === pageIndex && styles.active)}
|
||||
size="small"
|
||||
title={<DangerousRaw>{page}</DangerousRaw>}
|
||||
/>
|
||||
)}
|
||||
previousLabel={<Button className={styles.button} size="small" icon={<Previous />} />}
|
||||
nextLabel={<Button className={styles.button} size="small" icon={<Next />} />}
|
||||
breakLabel={
|
||||
<Button className={styles.button} size="small" title={<DangerousRaw>...</DangerousRaw>} />
|
||||
}
|
||||
disabledClassName={styles.disabled}
|
||||
onPageChange={({ selected }) => {
|
||||
onChange?.(selected + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
border: 1px solid var(--color-line-divider);
|
||||
display: flex;
|
||||
padding: _.unit(3) _.unit(8);
|
||||
justify-content: flex-end;
|
||||
background-color: var(--color-float);
|
||||
box-shadow: var(--shadow-3);
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-left: _.unit(3);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import Button from '../Button';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: () => Promise<void>;
|
||||
onDiscard: () => void;
|
||||
};
|
||||
|
||||
const SubmitFormChangesActionBar = ({ isOpen, isSubmitting, onSubmit, onDiscard }: Props) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Button
|
||||
size="medium"
|
||||
title="general.discard"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => {
|
||||
onDiscard();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
type="primary"
|
||||
size="medium"
|
||||
title="general.save_changes"
|
||||
onClick={async () => onSubmit()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitFormChangesActionBar;
|
|
@ -1,27 +1,62 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.link {
|
||||
font: var(--font-subhead-2);
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: _.unit(6);
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding-bottom: _.unit(1);
|
||||
.link {
|
||||
font: var(--font-subhead-2);
|
||||
padding: _.unit(0.5) _.unit(1.5);
|
||||
margin-bottom: _.unit(1);
|
||||
border-radius: 4px;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover-variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--color-text-link);
|
||||
border-bottom: 2px solid var(--color-text-link);
|
||||
margin-bottom: -1px;
|
||||
|
||||
a {
|
||||
.selected {
|
||||
position: relative;
|
||||
color: var(--color-text-link);
|
||||
|
||||
a {
|
||||
color: var(--color-text-link);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
// Note: link item's margin-bottom (_.unit(1)) + TabNav's border-bottom width (1px)
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 2px solid var(--color-text-link);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.errors {
|
||||
margin-left: _.unit(0.5);
|
||||
font: var(--font-label-medium);
|
||||
color: var(--color-white);
|
||||
padding: _.unit(0.5) _.unit(1.5);
|
||||
background-color: var(--color-error-50);
|
||||
border-radius: 10px;
|
||||
vertical-align: middle;
|
||||
margin-bottom: _.unit(1);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { onKeyDownHandler } from '@/utilities/a11y';
|
||||
|
@ -8,23 +9,30 @@ import * as styles from './TabNavItem.module.scss';
|
|||
type Props = {
|
||||
href?: string;
|
||||
isActive?: boolean;
|
||||
errorCount?: number;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const TabNavItem = ({ children, href, isActive, onClick }: Props) => {
|
||||
const TabNavItem = ({ children, href, isActive, errorCount = 0, onClick }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const location = useLocation();
|
||||
const selected = href ? location.pathname === href : isActive;
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.link, selected && styles.selected)}>
|
||||
{href ? (
|
||||
<Link to={href}>{children}</Link>
|
||||
) : (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-is-valid
|
||||
<a role="tab" tabIndex={0} onKeyDown={onKeyDownHandler(onClick)} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
<div className={styles.item}>
|
||||
<div className={classNames(styles.link, selected && styles.selected)}>
|
||||
{href ? (
|
||||
<Link to={href}>{children}</Link>
|
||||
) : (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-is-valid
|
||||
<a role="tab" tabIndex={0} onKeyDown={onKeyDownHandler(onClick)} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{errorCount > 0 && (
|
||||
<div className={styles.errors}>{t('general.tab_errors', { count: errorCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.nav {
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
border-bottom: 1px solid var(--color-surface-5);
|
||||
display: flex;
|
||||
margin: _.unit(1) 0;
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
|
|
@ -20,11 +20,19 @@ type BlockerNavigator = Navigator & {
|
|||
|
||||
type Props = {
|
||||
hasUnsavedChanges: boolean;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
|
||||
const UnsavedChangesAlertModal = ({ hasUnsavedChanges, parentPath }: Props) => {
|
||||
const { navigator } = useContext(UNSAFE_NavigationContext);
|
||||
|
||||
/**
|
||||
* Props `block` and `location` are removed from `Navigator` type in react-router, for the same reason as above.
|
||||
* So we have to define our own type `BlockerNavigator` to acquire these props that actually exist in `navigator` object.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const { block, location } = navigator as BlockerNavigator;
|
||||
|
||||
const [displayAlert, setDisplayAlert] = useState(false);
|
||||
const [transition, setTransition] = useState<Transition>();
|
||||
|
||||
|
@ -35,12 +43,6 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props `block` and `location` are removed from `Navigator` type in react-router, for the same reason as above.
|
||||
* So we have to define our own type `BlockerNavigator` to acquire these props that actually exist in `navigator` object.
|
||||
*/
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const { block, location } = navigator as BlockerNavigator;
|
||||
const { pathname } = location;
|
||||
|
||||
const unblock = block((transition) => {
|
||||
|
@ -53,6 +55,13 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (parentPath && targetPathname.startsWith(parentPath)) {
|
||||
unblock();
|
||||
transition.retry();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayAlert(true);
|
||||
|
||||
setTransition({
|
||||
|
@ -65,7 +74,7 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
|
|||
});
|
||||
|
||||
return unblock;
|
||||
}, [navigator, hasUnsavedChanges]);
|
||||
}, [navigator, hasUnsavedChanges, location, block, parentPath]);
|
||||
|
||||
const leavePage = useCallback(() => {
|
||||
transition?.retry();
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import type { ConnectorResponse } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import type { ConnectorGroup } from '@/types/connector';
|
||||
import { getConnectorGroups } from '@/pages/Connectors/utils';
|
||||
|
||||
// Group connectors by target
|
||||
const useConnectorGroups = () => {
|
||||
|
@ -15,42 +14,7 @@ const useConnectorGroups = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
return data.reduce<ConnectorGroup[]>((previous, item) => {
|
||||
const groupIndex = previous.findIndex(
|
||||
// Only group social connectors
|
||||
({ target }) => target === item.target && item.type === ConnectorType.Social
|
||||
);
|
||||
|
||||
if (groupIndex === -1) {
|
||||
return [
|
||||
...previous,
|
||||
{
|
||||
id: item.id, // Take first connector's id as groupId, only used for indexing.
|
||||
name: item.name,
|
||||
logo: item.logo,
|
||||
logoDark: item.logoDark,
|
||||
description: item.description,
|
||||
target: item.target,
|
||||
type: item.type,
|
||||
enabled: item.enabled,
|
||||
connectors: [item],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return previous.map((group, index) => {
|
||||
if (index !== groupIndex) {
|
||||
return group;
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
connectors: [...group.connectors, item],
|
||||
// Group is enabled when any of its connectors is enabled.
|
||||
enabled: group.enabled || item.enabled,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
return getConnectorGroups(data);
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
|
|
25
packages/console/src/hooks/use-enabled-connector-types.ts
Normal file
25
packages/console/src/hooks/use-enabled-connector-types.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { ConnectorResponse, ConnectorType } from '@logto/schemas';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import type { RequestError } from './use-api';
|
||||
|
||||
const useEnabledConnectorTypes = () => {
|
||||
const { data: connectors } = useSWR<ConnectorResponse[], RequestError>('/api/connectors');
|
||||
|
||||
const enabledConnectorTypes = useMemo(
|
||||
() => connectors?.map(({ type }) => type) ?? [],
|
||||
[connectors]
|
||||
);
|
||||
|
||||
const isConnectorTypeEnabled = useCallback(
|
||||
(connectorType: ConnectorType) => enabledConnectorTypes.includes(connectorType),
|
||||
[enabledConnectorTypes]
|
||||
);
|
||||
|
||||
return {
|
||||
isConnectorTypeEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default useEnabledConnectorTypes;
|
|
@ -5,13 +5,16 @@
|
|||
align-items: flex-start;
|
||||
position: relative;
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
|
||||
.multiTextInput {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
position: absolute;
|
||||
left: calc(556px + _.unit(3));
|
||||
top: 0;
|
||||
flex-shrink: 0;
|
||||
margin: _.unit(6) 0 0 _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 556px;
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ import useSWR from 'swr';
|
|||
|
||||
import Button from '@/components/Button';
|
||||
import FormField from '@/components/FormField';
|
||||
import MultiTextInput from '@/components/MultiTextInput';
|
||||
import { convertRhfErrorMessage, createValidatorForRhf } from '@/components/MultiTextInput/utils';
|
||||
import MultiTextInputField from '@/components/MultiTextInputField';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
@ -69,31 +69,33 @@ const UriInputField = ({ appId, name, title, isSingle = false }: Props) => {
|
|||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form>
|
||||
<FormField isRequired={name === 'redirectUris'} className={styles.field} title={title}>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={data?.oidcClientMetadata[name]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
required: t(
|
||||
isSingle
|
||||
? 'errors.required_field_missing'
|
||||
: 'errors.required_field_missing_plural',
|
||||
{ field: title }
|
||||
),
|
||||
pattern: {
|
||||
verify: (value) => !value || uriValidator(value),
|
||||
message: t('errors.invalid_uri_format'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value = [] }, fieldState: { error, isDirty } }) => {
|
||||
const errorObject = convertRhfErrorMessage(error?.message);
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={data?.oidcClientMetadata[name]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
required: t(
|
||||
isSingle ? 'errors.required_field_missing' : 'errors.required_field_missing_plural',
|
||||
{ field: title }
|
||||
),
|
||||
pattern: {
|
||||
verify: (value) => !value || uriValidator(value),
|
||||
message: t('errors.invalid_uri_format'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value = [] }, fieldState: { error, isDirty } }) => {
|
||||
const errorObject = convertRhfErrorMessage(error?.message);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.wrapper}>
|
||||
{isSingle && (
|
||||
return (
|
||||
<div ref={ref} className={styles.wrapper}>
|
||||
{isSingle && (
|
||||
<FormField
|
||||
isRequired={name === 'redirectUris'}
|
||||
className={styles.field}
|
||||
title={title}
|
||||
>
|
||||
<TextInput
|
||||
className={styles.field}
|
||||
value={value[0]}
|
||||
|
@ -105,31 +107,34 @@ const UriInputField = ({ appId, name, title, isSingle = false }: Props) => {
|
|||
onKeyPress(event, value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isSingle && (
|
||||
<MultiTextInput
|
||||
title={title}
|
||||
value={value}
|
||||
error={errorObject}
|
||||
onChange={onChange}
|
||||
onKeyPress={(event) => {
|
||||
onKeyPress(event, value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className={styles.saveButton}
|
||||
disabled={!isDirty}
|
||||
isLoading={isSubmitting}
|
||||
title="general.save"
|
||||
type="primary"
|
||||
onClick={handleSubmit(async () => onSubmit(value))}
|
||||
</FormField>
|
||||
)}
|
||||
{!isSingle && (
|
||||
<MultiTextInputField
|
||||
isRequired={name === 'redirectUris'}
|
||||
formFieldClassName={styles.field}
|
||||
title={title}
|
||||
value={value}
|
||||
error={errorObject}
|
||||
className={styles.multiTextInput}
|
||||
onChange={onChange}
|
||||
onKeyPress={(event) => {
|
||||
onKeyPress(event, value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<Button
|
||||
className={styles.saveButton}
|
||||
disabled={!isDirty}
|
||||
isLoading={isSubmitting}
|
||||
title="general.save"
|
||||
type="primary"
|
||||
onClick={handleSubmit(async () => onSubmit(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
|
|
|
@ -59,22 +59,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-top: _.unit(8);
|
||||
}
|
||||
|
||||
.fields {
|
||||
padding-bottom: _.unit(10);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type { Resource } from '@logto/schemas';
|
||||
import { AppearanceMode } from '@logto/schemas';
|
||||
import { managementResource } from '@logto/schemas/lib/seeds';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
@ -15,11 +14,12 @@ import Back from '@/assets/images/back.svg';
|
|||
import Delete from '@/assets/images/delete.svg';
|
||||
import More from '@/assets/images/more.svg';
|
||||
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import DetailsSkeleton from '@/components/DetailsSkeleton';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import LinkButton from '@/components/LinkButton';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
|
@ -159,47 +159,39 @@ const ApiResourceDetails = () => {
|
|||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Card className={classNames(styles.body, detailsStyles.body)}>
|
||||
<TabNav>
|
||||
<TabNavItem href={location.pathname}>{t('general.settings_nav')}</TabNavItem>
|
||||
</TabNav>
|
||||
<form className={classNames(styles.form, detailsStyles.body)} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
<FormField isRequired title="api_resources.api_name" className={styles.textField}>
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
hasError={Boolean(errors.name)}
|
||||
readOnly={isLogtoManagementApiResource}
|
||||
placeholder={t('api_resources.api_name_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="api_resource_details.token_expiration_time_in_seconds"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput
|
||||
{...register('accessTokenTtl', { required: true, valueAsNumber: true })}
|
||||
hasError={Boolean(errors.accessTokenTtl)}
|
||||
placeholder={t(
|
||||
'api_resource_details.token_expiration_time_in_seconds_placeholder'
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
title="general.save_changes"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<TabNav>
|
||||
<TabNavItem href={location.pathname}>{t('general.settings_nav')}</TabNavItem>
|
||||
</TabNav>
|
||||
<DetailsForm
|
||||
isDirty={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<FormCard
|
||||
title="api_resource_details.settings"
|
||||
description="api_resource_details.settings_description"
|
||||
learnMoreLink="https://docs.logto.io/docs/recipes/protect-your-api"
|
||||
>
|
||||
<FormField isRequired title="api_resources.api_name">
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
hasError={Boolean(errors.name)}
|
||||
readOnly={isLogtoManagementApiResource}
|
||||
placeholder={t('api_resources.api_name_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField isRequired title="api_resource_details.token_expiration_time_in_seconds">
|
||||
<TextInput
|
||||
{...register('accessTokenTtl', { required: true, valueAsNumber: true })}
|
||||
hasError={Boolean(errors.accessTokenTtl)}
|
||||
placeholder={t(
|
||||
'api_resource_details.token_expiration_time_in_seconds_placeholder'
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
</>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
|
||||
|
|
|
@ -1,26 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: _.unit(4);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.apiResourceName {
|
||||
|
|
|
@ -12,7 +12,6 @@ import ApiResourceDark from '@/assets/images/api-resource-dark.svg';
|
|||
import ApiResource from '@/assets/images/api-resource.svg';
|
||||
import Plus from '@/assets/images/plus.svg';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import ItemPreview from '@/components/ItemPreview';
|
||||
|
@ -23,6 +22,7 @@ import TableLoading from '@/components/Table/TableLoading';
|
|||
import type { RequestError } from '@/hooks/use-api';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
import * as tableStyles from '@/scss/table.module.scss';
|
||||
|
||||
import CreateForm from './components/CreateForm';
|
||||
|
@ -46,8 +46,8 @@ const ApiResources = () => {
|
|||
const [apiResources, totalCount] = data ?? [];
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.headline}>
|
||||
<div className={resourcesStyles.container}>
|
||||
<div className={resourcesStyles.headline}>
|
||||
<CardTitle title="api_resources.title" subtitle="api_resources.subtitle" />
|
||||
<Button
|
||||
title="api_resources.create"
|
||||
|
@ -77,78 +77,78 @@ const ApiResources = () => {
|
|||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table className={classNames(!data && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.apiResourceName} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('api_resources.api_name')}</th>
|
||||
<th>{t('api_resources.api_identifier')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={2}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={2} />}
|
||||
{apiResources?.length === 0 && (
|
||||
<TableEmpty columns={2}>
|
||||
<Button
|
||||
title="api_resources.create"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setIsCreateFormOpen(true);
|
||||
}}
|
||||
<div className={resourcesStyles.table}>
|
||||
<div className={tableStyles.scrollable}>
|
||||
<table className={classNames(!data && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.apiResourceName} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('api_resources.api_name')}</th>
|
||||
<th>{t('api_resources.api_identifier')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={2}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
</TableEmpty>
|
||||
)}
|
||||
{apiResources?.map(({ id, name, indicator }) => {
|
||||
const ResourceIcon =
|
||||
theme === AppearanceMode.LightMode ? ApiResource : ApiResourceDark;
|
||||
)}
|
||||
{isLoading && <TableLoading columns={2} />}
|
||||
{apiResources?.length === 0 && (
|
||||
<TableEmpty columns={2}>
|
||||
<Button
|
||||
title="api_resources.create"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setIsCreateFormOpen(true);
|
||||
}}
|
||||
/>
|
||||
</TableEmpty>
|
||||
)}
|
||||
{apiResources?.map(({ id, name, indicator }) => {
|
||||
const ResourceIcon =
|
||||
theme === AppearanceMode.LightMode ? ApiResource : ApiResourceDark;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(buildDetailsLink(id));
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<ItemPreview
|
||||
title={name}
|
||||
icon={<ResourceIcon className={styles.icon} />}
|
||||
to={buildDetailsLink(id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<CopyToClipboard value={indicator} variant="text" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(buildDetailsLink(id));
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<ItemPreview
|
||||
title={name}
|
||||
icon={<ResourceIcon className={styles.icon} />}
|
||||
to={buildDetailsLink(id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<CopyToClipboard value={indicator} variant="text" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagination}>
|
||||
{!!totalCount && (
|
||||
<Pagination
|
||||
pageCount={Math.ceil(totalCount / pageSize)}
|
||||
pageIndex={pageIndex}
|
||||
onChange={(page) => {
|
||||
setQuery({ page: String(page) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Pagination
|
||||
pageIndex={pageIndex}
|
||||
totalCount={totalCount ?? 0}
|
||||
pageSize={pageSize}
|
||||
className={styles.pagination}
|
||||
onChange={(page) => {
|
||||
setQuery({ page: String(page) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,44 +1,32 @@
|
|||
import type { Application, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import { ApplicationType, UserRole } from '@logto/schemas';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import Switch from '@/components/Switch';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
type Props = {
|
||||
applicationType: ApplicationType;
|
||||
oidcConfig: SnakeCaseOidcConfig;
|
||||
defaultData: Application;
|
||||
isDeleted: boolean;
|
||||
};
|
||||
|
||||
const AdvancedSettings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props) => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = useFormContext<Application>();
|
||||
const AdvancedSettings = ({ applicationType, oidcConfig }: Props) => {
|
||||
const { control } = useFormContext<Application>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormCard
|
||||
title="application_details.advanced_settings"
|
||||
description="application_details.advanced_settings_description"
|
||||
learnMoreLink="https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint"
|
||||
>
|
||||
<FormField
|
||||
title="application_details.authorization_endpoint"
|
||||
className={styles.textField}
|
||||
tooltip="application_details.authorization_endpoint_tip"
|
||||
>
|
||||
<CopyToClipboard
|
||||
|
@ -83,8 +71,7 @@ const AdvancedSettings = ({ applicationType, oidcConfig, defaultData, isDeleted
|
|||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
|
||||
</>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,43 +1,32 @@
|
|||
import type { Application, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import type { Application } from '@logto/schemas';
|
||||
import { ApplicationType, validateRedirectUrl } from '@logto/schemas';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import MultiTextInput from '@/components/MultiTextInput';
|
||||
import type { MultiTextInputRule } from '@/components/MultiTextInput/types';
|
||||
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
|
||||
import MultiTextInputField from '@/components/MultiTextInputField';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import { uriOriginValidator } from '@/utilities/validator';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
type Props = {
|
||||
applicationType: ApplicationType;
|
||||
oidcConfig: SnakeCaseOidcConfig;
|
||||
defaultData: Application;
|
||||
isDeleted: boolean;
|
||||
data: Application;
|
||||
};
|
||||
|
||||
const Settings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props) => {
|
||||
const Settings = ({ data }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
formState: { errors },
|
||||
} = useFormContext<Application>();
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultData);
|
||||
|
||||
return () => {
|
||||
reset(defaultData);
|
||||
};
|
||||
}, [reset, defaultData]);
|
||||
const { id, secret, type: applicationType } = data;
|
||||
|
||||
const isNativeApp = applicationType === ApplicationType.Native;
|
||||
const uriPatternRules: MultiTextInputRule = {
|
||||
|
@ -48,129 +37,113 @@ const Settings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
isRequired
|
||||
title="application_details.application_name"
|
||||
className={styles.textField}
|
||||
>
|
||||
<FormCard
|
||||
title="application_details.settings"
|
||||
description="application_details.settings_description"
|
||||
learnMoreLink="https://docs.logto.io/docs/references/applications"
|
||||
>
|
||||
<FormField isRequired title="application_details.application_name">
|
||||
<TextInput
|
||||
{...register('name', { required: true })}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder={t('application_details.application_name_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="application_details.description" className={styles.textField}>
|
||||
<FormField title="application_details.description">
|
||||
<TextInput
|
||||
{...register('description')}
|
||||
placeholder={t('application_details.description_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="application_details.application_id" className={styles.textField}>
|
||||
<CopyToClipboard className={styles.textField} value={defaultData.id} variant="border" />
|
||||
<FormField title="application_details.application_id">
|
||||
<CopyToClipboard value={id} variant="border" className={styles.textField} />
|
||||
</FormField>
|
||||
{[ApplicationType.Traditional, ApplicationType.MachineToMachine].includes(
|
||||
applicationType
|
||||
) && (
|
||||
<FormField title="application_details.application_secret" className={styles.textField}>
|
||||
<FormField title="application_details.application_secret">
|
||||
<CopyToClipboard
|
||||
hasVisibilityToggle
|
||||
className={styles.textField}
|
||||
value={defaultData.secret}
|
||||
value={secret}
|
||||
variant="border"
|
||||
className={styles.textField}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{applicationType !== ApplicationType.MachineToMachine && (
|
||||
<FormField
|
||||
isRequired
|
||||
title="application_details.redirect_uris"
|
||||
className={styles.textField}
|
||||
tooltip="application_details.redirect_uri_tip"
|
||||
>
|
||||
<Controller
|
||||
name="oidcClientMetadata.redirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
...uriPatternRules,
|
||||
required: t('application_details.redirect_uri_required'),
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
title="application_details.redirect_uris"
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
placeholder={
|
||||
applicationType === ApplicationType.Native
|
||||
? t('application_details.redirect_uri_placeholder_native')
|
||||
: t('application_details.redirect_uri_placeholder')
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="oidcClientMetadata.redirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
...uriPatternRules,
|
||||
required: t('application_details.redirect_uri_required'),
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInputField
|
||||
isRequired
|
||||
title="application_details.redirect_uris"
|
||||
tooltip="application_details.redirect_uri_tip"
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
placeholder={
|
||||
applicationType === ApplicationType.Native
|
||||
? t('application_details.redirect_uri_placeholder_native')
|
||||
: t('application_details.redirect_uri_placeholder')
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{applicationType !== ApplicationType.MachineToMachine && (
|
||||
<FormField
|
||||
title="application_details.post_sign_out_redirect_uris"
|
||||
className={styles.textField}
|
||||
tooltip="application_details.post_sign_out_redirect_uri_tip"
|
||||
>
|
||||
<Controller
|
||||
name="oidcClientMetadata.postLogoutRedirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf(uriPatternRules),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
title="application_details.post_sign_out_redirect_uris"
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
placeholder={t('application_details.post_sign_out_redirect_uri_placeholder')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="oidcClientMetadata.postLogoutRedirectUris"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf(uriPatternRules),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInputField
|
||||
title="application_details.post_sign_out_redirect_uris"
|
||||
tooltip="application_details.post_sign_out_redirect_uri_tip"
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
placeholder={t('application_details.post_sign_out_redirect_uri_placeholder')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{applicationType !== ApplicationType.MachineToMachine && (
|
||||
<FormField
|
||||
title="application_details.cors_allowed_origins"
|
||||
className={styles.textField}
|
||||
tooltip="application_details.cors_allowed_origins_tip"
|
||||
>
|
||||
<Controller
|
||||
name="customClientMetadata.corsAllowedOrigins"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
pattern: {
|
||||
verify: (value) => !value || uriOriginValidator(value),
|
||||
message: t('errors.invalid_origin_format'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInput
|
||||
title="application_details.cors_allowed_origins"
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
placeholder={t('application_details.cors_allowed_origins_placeholder')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="customClientMetadata.corsAllowedOrigins"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
validate: createValidatorForRhf({
|
||||
pattern: {
|
||||
verify: (value) => !value || uriOriginValidator(value),
|
||||
message: t('errors.invalid_origin_format'),
|
||||
},
|
||||
}),
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<MultiTextInputField
|
||||
title="application_details.cors_allowed_origins"
|
||||
tooltip="application_details.cors_allowed_origins_tip"
|
||||
value={value}
|
||||
error={convertRhfErrorMessage(error?.message)}
|
||||
placeholder={t('application_details.cors_allowed_origins_placeholder')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
|
||||
</>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -18,23 +18,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-top: _.unit(8);
|
||||
}
|
||||
|
||||
.fields {
|
||||
padding-bottom: _.unit(10);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
}
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { Application, SnakeCaseOidcConfig } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
@ -17,10 +16,12 @@ import Button from '@/components/Button';
|
|||
import Card from '@/components/Card';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import DetailsSkeleton from '@/components/DetailsSkeleton';
|
||||
import Drawer from '@/components/Drawer';
|
||||
import LinkButton from '@/components/LinkButton';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||
|
@ -62,7 +63,7 @@ const ApplicationDetails = () => {
|
|||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
formState: { isSubmitting, isDirty },
|
||||
} = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -123,8 +124,6 @@ const ApplicationDetails = () => {
|
|||
setIsReadmeOpen(false);
|
||||
};
|
||||
|
||||
const isAdvancedSettings = pathname.includes('advanced-settings');
|
||||
|
||||
return (
|
||||
<div className={detailsStyles.container}>
|
||||
<LinkButton
|
||||
|
@ -200,51 +199,25 @@ const ApplicationDetails = () => {
|
|||
</DeleteConfirmModal>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={classNames(styles.body, detailsStyles.body)}>
|
||||
<TabNav>
|
||||
<TabNavItem href={`/applications/${data.id}/settings`}>
|
||||
{t('general.settings_nav')}
|
||||
</TabNavItem>
|
||||
<TabNavItem href={`/applications/${data.id}/advanced-settings`}>
|
||||
{t('application_details.advanced_settings')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<FormProvider {...formMethods}>
|
||||
<form className={classNames(styles.form, detailsStyles.body)} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
{isAdvancedSettings && (
|
||||
<AdvancedSettings
|
||||
applicationType={data.type}
|
||||
oidcConfig={oidcConfig}
|
||||
defaultData={data}
|
||||
isDeleted={isDeleted}
|
||||
/>
|
||||
)}
|
||||
{!isAdvancedSettings && (
|
||||
<Settings
|
||||
applicationType={data.type}
|
||||
oidcConfig={oidcConfig}
|
||||
defaultData={data}
|
||||
isDeleted={isDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
size="large"
|
||||
title="general.save_changes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Card>
|
||||
<TabNav>
|
||||
<TabNavItem href={`/applications/${data.id}/settings`}>
|
||||
{t('general.settings_nav')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<FormProvider {...formMethods}>
|
||||
<DetailsForm
|
||||
isDirty={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Settings data={data} />
|
||||
<AdvancedSettings applicationType={data.type} oidcConfig={oidcConfig} />
|
||||
</DetailsForm>
|
||||
</FormProvider>
|
||||
</>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,26 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: _.unit(4);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.applicationName {
|
||||
|
|
|
@ -9,7 +9,6 @@ import useSWR from 'swr';
|
|||
import Plus from '@/assets/images/plus.svg';
|
||||
import ApplicationIcon from '@/components/ApplicationIcon';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import ItemPreview from '@/components/ItemPreview';
|
||||
|
@ -19,6 +18,7 @@ import TableError from '@/components/Table/TableError';
|
|||
import TableLoading from '@/components/Table/TableLoading';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
import * as tableStyles from '@/scss/table.module.scss';
|
||||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
|
||||
|
@ -41,8 +41,8 @@ const Applications = () => {
|
|||
const [applications, totalCount] = data ?? [];
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.headline}>
|
||||
<div className={resourcesStyles.container}>
|
||||
<div className={resourcesStyles.headline}>
|
||||
<CardTitle title="applications.title" subtitle="applications.subtitle" />
|
||||
<Button
|
||||
icon={<Plus />}
|
||||
|
@ -69,75 +69,77 @@ const Applications = () => {
|
|||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table className={classNames(!data && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.applicationName} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('applications.application_name')}</th>
|
||||
<th>{t('applications.app_id')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={2}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={2} />}
|
||||
{applications?.length === 0 && (
|
||||
<TableEmpty columns={2}>
|
||||
<Button
|
||||
title="applications.create"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
navigate('/applications/create');
|
||||
}}
|
||||
/>
|
||||
</TableEmpty>
|
||||
)}
|
||||
{applications?.map(({ id, name, type }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`/applications/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<ItemPreview
|
||||
title={name}
|
||||
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
|
||||
icon={<ApplicationIcon className={styles.icon} type={type} />}
|
||||
to={`/applications/${id}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<CopyToClipboard value={id} variant="text" />
|
||||
</td>
|
||||
</div>{' '}
|
||||
<div className={resourcesStyles.table}>
|
||||
<div className={tableStyles.scrollable}>
|
||||
<table className={classNames(!data && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.applicationName} />
|
||||
<col />
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('applications.application_name')}</th>
|
||||
<th>{t('applications.app_id')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={2}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={2} />}
|
||||
{applications?.length === 0 && (
|
||||
<TableEmpty columns={2}>
|
||||
<Button
|
||||
title="applications.create"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
navigate('/applications/create');
|
||||
}}
|
||||
/>
|
||||
</TableEmpty>
|
||||
)}
|
||||
{applications?.map(({ id, name, type }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`/applications/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<ItemPreview
|
||||
title={name}
|
||||
subtitle={t(`${applicationTypeI18nKey[type]}.title`)}
|
||||
icon={<ApplicationIcon className={styles.icon} type={type} />}
|
||||
to={`/applications/${id}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<CopyToClipboard value={id} variant="text" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagination}>
|
||||
{!!totalCount && (
|
||||
<Pagination
|
||||
pageCount={Math.ceil(totalCount / pageSize)}
|
||||
pageIndex={pageIndex}
|
||||
onChange={(page) => {
|
||||
setQuery({ page: String(page) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Pagination
|
||||
pageIndex={pageIndex}
|
||||
totalCount={totalCount ?? 0}
|
||||
pageSize={pageSize}
|
||||
className={styles.pagination}
|
||||
onChange={(page) => {
|
||||
setQuery({ page: String(page) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -46,6 +46,8 @@
|
|||
}
|
||||
|
||||
.body {
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
|
|
@ -24,6 +24,9 @@ import * as styles from './index.module.scss';
|
|||
const getAuditLogDetailsRelatedResourceLink = (pathname: string) =>
|
||||
`/${pathname.slice(0, pathname.lastIndexOf('/'))}`;
|
||||
|
||||
const getDetailsTabNavLink = (logId: string, userId?: string) =>
|
||||
userId ? `/users/${userId}/logs/${logId}` : `/audit-logs/${logId}`;
|
||||
|
||||
const AuditLogDetails = () => {
|
||||
const { userId, logId } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
|
@ -42,6 +45,10 @@ const AuditLogDetails = () => {
|
|||
'log_details.back_to_logs'
|
||||
);
|
||||
|
||||
if (!logId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={detailsStyles.container}>
|
||||
<LinkButton to={backLink} icon={<Back />} title={backLinkTitle} className={styles.backLink} />
|
||||
|
@ -95,12 +102,12 @@ const AuditLogDetails = () => {
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<TabNav>
|
||||
<TabNavItem href={getDetailsTabNavLink(logId, userId)}>
|
||||
{t('log_details.tab_details')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<Card className={classNames(styles.body, detailsStyles.body)}>
|
||||
<TabNav>
|
||||
<TabNavItem href={`/audit-logs/${logId ?? ''}`}>
|
||||
{t('log_details.tab_details')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<div className={styles.main}>
|
||||
<FormField title="log_details.raw_data">
|
||||
<CodeEditor language="json" value={JSON.stringify(data.payload, null, 2)} />
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
|
@ -1,17 +1,17 @@
|
|||
import AuditLogTable from '@/components/AuditLogTable';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
|
||||
const AuditLogs = () => {
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.headline}>
|
||||
<div className={resourcesStyles.container}>
|
||||
<div className={resourcesStyles.headline}>
|
||||
<CardTitle title="logs.title" subtitle="logs.subtitle" />
|
||||
</div>
|
||||
<AuditLogTable />
|
||||
</Card>
|
||||
<div className={resourcesStyles.table}>
|
||||
<AuditLogTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import type { Connector, ConnectorResponse, ConnectorMetadata } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
import { safeParseJson } from '@/utilities/json';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
@ -24,15 +25,15 @@ type Props = {
|
|||
|
||||
const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { connectorId } = useParams();
|
||||
const api = useApi();
|
||||
const methods = useForm<{ configJson: string }>({ reValidateMode: 'onBlur' });
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting, isDirty },
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
} = methods;
|
||||
} = useForm<{ configJson: string }>({ reValidateMode: 'onBlur' });
|
||||
|
||||
const defaultConfig = useMemo(() => {
|
||||
const hasData = Object.keys(connectorData.config).length > 0;
|
||||
|
@ -40,6 +41,10 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
|
|||
return hasData ? JSON.stringify(connectorData.config, null, 2) : connectorData.configTemplate;
|
||||
}, [connectorData]);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [connectorId, reset]);
|
||||
|
||||
const onSubmit = handleSubmit(async ({ configJson }) => {
|
||||
if (!configJson) {
|
||||
toast.error(t('connector_details.save_error_empty_config'));
|
||||
|
@ -66,8 +71,17 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.main}>
|
||||
<form {...methods}>
|
||||
<DetailsForm
|
||||
isDirty={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<FormCard
|
||||
title="connector_details.settings"
|
||||
description="connector_details.settings_description"
|
||||
learnMoreLink="https://docs.logto.io/docs/references/connectors"
|
||||
>
|
||||
<Controller
|
||||
name="configJson"
|
||||
control={control}
|
||||
|
@ -83,26 +97,16 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
|
|||
</FormField>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
{connectorData.type !== ConnectorType.Social && (
|
||||
<SenderTester
|
||||
connectorId={connectorData.id}
|
||||
connectorType={connectorData.type}
|
||||
config={watch('configJson')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
title="general.save_changes"
|
||||
isLoading={isSubmitting}
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{connectorData.type !== ConnectorType.Social && (
|
||||
<SenderTester
|
||||
className={styles.senderTest}
|
||||
connectorId={connectorData.id}
|
||||
connectorType={connectorData.type}
|
||||
config={watch('configJson')}
|
||||
/>
|
||||
)}
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type { ConnectorResponse } from '@logto/schemas';
|
||||
import { ConnectorPlatform } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
@ -18,15 +17,11 @@ type Props = {
|
|||
|
||||
const ConnectorTabs = ({ target, connectorId }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data } = useSWR<ConnectorResponse[]>(`/api/connectors?target=${target}`);
|
||||
const { data: connectors } = useSWR<ConnectorResponse[]>(`/api/connectors?target=${target}`);
|
||||
|
||||
const connectors = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.filter(({ enabled }) => enabled);
|
||||
}, [data]);
|
||||
if (!connectors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (connectors.length === 0) {
|
||||
return null;
|
||||
|
|
|
@ -57,6 +57,7 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props)
|
|||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
const { sendTo } = formData;
|
||||
|
||||
const result = safeParseJson(config);
|
||||
|
||||
if (!result.success) {
|
||||
|
@ -73,7 +74,7 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props)
|
|||
});
|
||||
|
||||
return (
|
||||
<form className={className}>
|
||||
<div className={className}>
|
||||
<div className={styles.fields}>
|
||||
<FormField
|
||||
isRequired
|
||||
|
@ -120,7 +121,7 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props)
|
|||
<div className={classNames(inputError?.message ? styles.error : styles.description)}>
|
||||
{inputError?.message ?? t('connector_details.test_sender_description')}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -76,18 +76,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
.codeEditor {
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
|
||||
.codeEditor {
|
||||
margin-bottom: _.unit(6);
|
||||
}
|
||||
}
|
||||
.senderTest {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
||||
.resetIcon {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { ConnectorResponse } from '@logto/schemas';
|
||||
import { AppearanceMode, ConnectorType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
@ -68,16 +67,13 @@ const ConnectorDetails = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
await api
|
||||
.patch(`/api/connectors/${connectorId}/enabled`, {
|
||||
json: { enabled: false },
|
||||
})
|
||||
.json<ConnectorResponse>();
|
||||
toast.success(t('connector_details.connector_deleted'));
|
||||
await api.delete(`/api/connectors/${connectorId}`).json<ConnectorResponse>();
|
||||
|
||||
await mutateGlobal('/api/connectors');
|
||||
setIsDeleted(true);
|
||||
|
||||
toast.success(t('connector_details.connector_deleted'));
|
||||
await mutateGlobal('/api/connectors');
|
||||
|
||||
if (isSocial) {
|
||||
navigate(`/connectors/social`, { replace: true });
|
||||
} else {
|
||||
|
@ -178,21 +174,19 @@ const ConnectorDetails = () => {
|
|||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<TabNav>
|
||||
<TabNavItem href={`/connectors/${connectorId ?? ''}`}>
|
||||
{t('general.settings_nav')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
{data && (
|
||||
<Card className={classNames(styles.body, detailsStyles.body)}>
|
||||
<TabNav>
|
||||
<TabNavItem href={`/connectors/${connectorId ?? ''}`}>
|
||||
{t('general.settings_nav')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<ConnectorContent
|
||||
isDeleted={isDeleted}
|
||||
connectorData={data}
|
||||
onConnectorUpdated={(connector) => {
|
||||
void mutate(connector);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<ConnectorContent
|
||||
isDeleted={isDeleted}
|
||||
connectorData={data}
|
||||
onConnectorUpdated={(connector) => {
|
||||
void mutate(connector);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{data && (
|
||||
<ConfirmModal
|
||||
|
|
|
@ -24,8 +24,7 @@ type Props = {
|
|||
|
||||
const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const enabledConnectors = connectors.filter(({ enabled }) => enabled);
|
||||
const connector = enabledConnectors[0];
|
||||
const connector = connectors[0];
|
||||
const theme = useTheme();
|
||||
|
||||
if (!connector) {
|
||||
|
@ -59,7 +58,7 @@ const ConnectorName = ({ type, connectors, onClickSetup }: Props) => {
|
|||
{type !== ConnectorType.Social && connector.id}
|
||||
{type === ConnectorType.Social && connectors.length > 1 && (
|
||||
<div className={styles.platforms}>
|
||||
{enabledConnectors.map(
|
||||
{connectors.map(
|
||||
({ id, platform }) =>
|
||||
platform && (
|
||||
<div key={id} className={styles.platform}>
|
||||
|
|
|
@ -19,17 +19,17 @@ type Props = {
|
|||
|
||||
const ConnectorRow = ({ type, connectors, onClickSetup }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const firstEnabledConnector = connectors.find(({ enabled }) => enabled);
|
||||
const inUse = useConnectorInUse(type, firstEnabledConnector?.target);
|
||||
const firstConnector = connectors[0];
|
||||
const inUse = useConnectorInUse(type, firstConnector?.target);
|
||||
const navigate = useNavigate();
|
||||
const showSetupButton = type !== ConnectorType.Social && !firstEnabledConnector;
|
||||
const showSetupButton = type !== ConnectorType.Social && !firstConnector;
|
||||
|
||||
const handleClickRow = () => {
|
||||
if (showSetupButton || !firstEnabledConnector) {
|
||||
if (showSetupButton || !firstConnector) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`/connectors/${firstEnabledConnector.id}`);
|
||||
navigate(`/connectors/${firstConnector.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { ConnectorFactoryResponse } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RadioGroup, { Radio } from '@/components/RadioGroup';
|
||||
|
@ -8,7 +9,7 @@ import type { ConnectorGroup } from '@/types/connector';
|
|||
import * as styles from './PlatformSelector.module.scss';
|
||||
|
||||
type Props = {
|
||||
connectorGroup: ConnectorGroup;
|
||||
connectorGroup: ConnectorGroup<ConnectorFactoryResponse & { added: boolean }>;
|
||||
connectorId?: string;
|
||||
onConnectorIdChange: (value: string) => void;
|
||||
};
|
||||
|
@ -28,13 +29,13 @@ const PlatformSelector = ({ connectorGroup, connectorId, onConnectorIdChange }:
|
|||
</div>
|
||||
<RadioGroup type="plain" name="connector" value={connectorId} onChange={onConnectorIdChange}>
|
||||
{connectorGroup.connectors.map(
|
||||
({ platform, id, enabled }) =>
|
||||
({ platform, id, added }) =>
|
||||
platform && (
|
||||
<Radio
|
||||
key={id}
|
||||
value={id}
|
||||
title={connectorPlatformLabel[platform]}
|
||||
isDisabled={enabled}
|
||||
isDisabled={added}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import ModalLayout from '@/components/ModalLayout';
|
||||
import RadioGroup, { Radio } from '@/components/RadioGroup';
|
||||
import UnnamedTrans from '@/components/UnnamedTrans';
|
||||
import useConnectorGroups from '@/hooks/use-connector-groups';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import { getConnectorGroups } from '../../utils';
|
||||
import Guide from '../Guide';
|
||||
import PlatformSelector from './PlatformSelector';
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -21,25 +24,45 @@ type Props = {
|
|||
};
|
||||
|
||||
const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
||||
const { data: allGroups, connectors, error } = useConnectorGroups();
|
||||
const isLoading = !allGroups && !connectors && !error;
|
||||
const { data: existingConnectors, error: connectorsError } = useSWR<
|
||||
ConnectorResponse[],
|
||||
RequestError
|
||||
>('/api/connectors');
|
||||
const { data: factories, error: factoriesError } = useSWR<
|
||||
ConnectorFactoryResponse[],
|
||||
RequestError
|
||||
>('/api/connector-factories');
|
||||
const isLoading = !factories && !existingConnectors && !connectorsError && !factoriesError;
|
||||
const [activeGroupId, setActiveGroupId] = useState<string>();
|
||||
const [activeConnectorId, setActiveConnectorId] = useState<string>();
|
||||
const [activeFactoryId, setActiveFactoryId] = useState<string>();
|
||||
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
|
||||
|
||||
const groups = useMemo(
|
||||
() => allGroups?.filter((group) => group.type === type),
|
||||
[allGroups, type]
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
if (!factories || !existingConnectors) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allGroups = getConnectorGroups<ConnectorFactoryResponse>(
|
||||
factories.filter(({ type: factoryType }) => factoryType === type)
|
||||
);
|
||||
|
||||
return allGroups.map((group) => ({
|
||||
...group,
|
||||
connectors: group.connectors.map((connector) => ({
|
||||
...connector,
|
||||
added: existingConnectors.some(({ connectorId }) => connector.id === connectorId),
|
||||
})),
|
||||
}));
|
||||
}, [factories, type, existingConnectors]);
|
||||
|
||||
const activeGroup = useMemo(
|
||||
() => groups?.find(({ id }) => id === activeGroupId),
|
||||
() => groups.find(({ id }) => id === activeGroupId),
|
||||
[activeGroupId, groups]
|
||||
);
|
||||
|
||||
const activeConnector = useMemo(
|
||||
() => connectors?.find(({ id }) => id === activeConnectorId),
|
||||
[activeConnectorId, connectors]
|
||||
const activeFactory = useMemo(
|
||||
() => factories?.find(({ id }) => id === activeFactoryId),
|
||||
[activeFactoryId, factories]
|
||||
);
|
||||
|
||||
const cardTitle = useMemo(() => {
|
||||
|
@ -55,10 +78,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
}, [type]);
|
||||
|
||||
const handleGroupChange = (groupId: string) => {
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveGroupId(groupId);
|
||||
|
||||
const group = groups.find(({ id }) => id === groupId);
|
||||
|
@ -67,20 +86,20 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const firstAvailableConnector = group.connectors.find(({ enabled }) => !enabled);
|
||||
const firstAvailableConnector = group.connectors.find(({ added }) => !added);
|
||||
|
||||
setActiveConnectorId(firstAvailableConnector?.id);
|
||||
setActiveFactoryId(firstAvailableConnector?.id);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsGetStartedModalOpen(false);
|
||||
onClose?.(activeConnectorId);
|
||||
onClose?.(activeFactoryId);
|
||||
setActiveGroupId(undefined);
|
||||
setActiveConnectorId(undefined);
|
||||
setActiveFactoryId(undefined);
|
||||
};
|
||||
|
||||
const modalSize = useMemo(() => {
|
||||
if (!groups || groups.length <= 2) {
|
||||
if (groups.length <= 2) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
|
@ -103,7 +122,7 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
<Button
|
||||
title="general.next"
|
||||
type="primary"
|
||||
disabled={!activeConnectorId}
|
||||
disabled={!activeFactoryId}
|
||||
onClick={() => {
|
||||
setIsGetStartedModalOpen(true);
|
||||
}}
|
||||
|
@ -114,53 +133,48 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
onClose={onClose}
|
||||
>
|
||||
{isLoading && 'Loading...'}
|
||||
{error?.message}
|
||||
{groups && (
|
||||
<RadioGroup
|
||||
name="group"
|
||||
value={activeGroupId}
|
||||
type="card"
|
||||
className={classNames(styles.connectorGroup, styles[modalSize])}
|
||||
onChange={handleGroupChange}
|
||||
>
|
||||
{groups.map(({ id, name, logo, description, connectors }) => {
|
||||
const isDisabled = connectors.every(({ enabled }) => enabled);
|
||||
{factoriesError?.message ?? connectorsError?.message}
|
||||
<RadioGroup
|
||||
name="group"
|
||||
value={activeGroupId}
|
||||
type="card"
|
||||
className={classNames(styles.connectorGroup, styles[modalSize])}
|
||||
onChange={handleGroupChange}
|
||||
>
|
||||
{groups.map(({ id, name, logo, description, connectors }) => {
|
||||
const isDisabled = connectors.every(({ added }) => added);
|
||||
|
||||
return (
|
||||
<Radio key={id} value={id} isDisabled={isDisabled} disabledLabel="general.added">
|
||||
<div className={styles.connector}>
|
||||
<div className={styles.logo}>
|
||||
<img src={logo} alt="logo" />
|
||||
return (
|
||||
<Radio key={id} value={id} isDisabled={isDisabled} disabledLabel="general.added">
|
||||
<div className={styles.connector}>
|
||||
<div className={styles.logo}>
|
||||
<img src={logo} alt="logo" />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div
|
||||
className={classNames(styles.name, isDisabled && styles.nameWithRightPadding)}
|
||||
>
|
||||
<UnnamedTrans resource={name} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.name,
|
||||
isDisabled && styles.nameWithRightPadding
|
||||
)}
|
||||
>
|
||||
<UnnamedTrans resource={name} />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<UnnamedTrans resource={description} />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<UnnamedTrans resource={description} />
|
||||
</div>
|
||||
</div>
|
||||
</Radio>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</Radio>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
{activeGroup && (
|
||||
<PlatformSelector
|
||||
connectorGroup={activeGroup}
|
||||
connectorId={activeConnectorId}
|
||||
onConnectorIdChange={setActiveConnectorId}
|
||||
connectorId={activeFactoryId}
|
||||
onConnectorIdChange={setActiveFactoryId}
|
||||
/>
|
||||
)}
|
||||
{activeConnector && (
|
||||
{activeFactory && (
|
||||
<Modal isOpen={isGetStartedModalOpen} className={modalStyles.fullScreen}>
|
||||
<Guide connector={activeConnector} onClose={closeModal} />
|
||||
<Guide connector={activeFactory} onClose={closeModal} />
|
||||
</Modal>
|
||||
)}
|
||||
</ModalLayout>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { isLanguageTag } from '@logto/language-kit';
|
||||
import type { ConnectorResponse } from '@logto/schemas';
|
||||
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import i18next from 'i18next';
|
||||
|
@ -23,7 +23,7 @@ import { safeParseJson } from '@/utilities/json';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
connector: ConnectorResponse;
|
||||
connector: ConnectorFactoryResponse;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
|
@ -57,11 +57,10 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { id: connectorId } = connector;
|
||||
|
||||
await api
|
||||
.patch(`/api/connectors/${connectorId}`, { json: { config: result.data } })
|
||||
.json<ConnectorResponse>();
|
||||
await api
|
||||
.patch(`/api/connectors/${connectorId}/enabled`, { json: { enabled: true } })
|
||||
.post('/api/connectors', { json: { config: result.data, connectorId } })
|
||||
.json<ConnectorResponse>();
|
||||
|
||||
await updateSettings({
|
||||
|
|
|
@ -16,13 +16,7 @@ const SignInExperienceSetupNotice = () => {
|
|||
update,
|
||||
} = useUserPreferences();
|
||||
|
||||
if (!connectors || connectorSieNoticeConfirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSetupConnector = connectors.some(({ enabled }) => enabled);
|
||||
|
||||
if (!hasSetupConnector) {
|
||||
if (!connectors || connectors.length === 0 || connectorSieNoticeConfirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +1,20 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
@include _.flex-column;
|
||||
}
|
||||
.container {
|
||||
.tabs {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.connectorName {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
font-size: var(--font-body-medium);
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connectorName {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
font-size: var(--font-body-medium);
|
||||
}
|
||||
|
||||
.emptyLine {
|
||||
padding: _.unit(2) 0;
|
||||
.emptyLine {
|
||||
padding: _.unit(2) 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import Plus from '@/assets/images/plus.svg';
|
|||
import SocialConnectorEmptyDark from '@/assets/images/social-connector-empty-dark.svg';
|
||||
import SocialConnectorEmpty from '@/assets/images/social-connector-empty.svg';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import TableEmpty from '@/components/Table/TableEmpty';
|
||||
|
@ -16,6 +15,7 @@ import TableError from '@/components/Table/TableError';
|
|||
import TableLoading from '@/components/Table/TableLoading';
|
||||
import useConnectorGroups from '@/hooks/use-connector-groups';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import * as resourcesStyles from '@/scss/resources.module.scss';
|
||||
import * as tableStyles from '@/scss/table.module.scss';
|
||||
|
||||
import ConnectorRow from './components/ConnectorRow';
|
||||
|
@ -35,17 +35,13 @@ const Connectors = () => {
|
|||
const isLightMode = theme === AppearanceMode.LightMode;
|
||||
|
||||
const emailConnector = useMemo(() => {
|
||||
const emailConnectorGroup = data?.find(
|
||||
({ enabled, type }) => enabled && type === ConnectorType.Email
|
||||
);
|
||||
const emailConnectorGroup = data?.find(({ type }) => type === ConnectorType.Email);
|
||||
|
||||
return emailConnectorGroup?.connectors[0];
|
||||
}, [data]);
|
||||
|
||||
const smsConnector = useMemo(() => {
|
||||
const smsConnectorGroup = data?.find(
|
||||
({ enabled, type }) => enabled && type === ConnectorType.Sms
|
||||
);
|
||||
const smsConnectorGroup = data?.find(({ type }) => type === ConnectorType.Sms);
|
||||
|
||||
return smsConnectorGroup?.connectors[0];
|
||||
}, [data]);
|
||||
|
@ -55,13 +51,13 @@ const Connectors = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
return data?.filter(({ enabled, type }) => enabled && type === ConnectorType.Social);
|
||||
return data?.filter(({ type }) => type === ConnectorType.Social);
|
||||
}, [data, isSocial]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.headline}>
|
||||
<div className={classNames(resourcesStyles.container, styles.container)}>
|
||||
<div className={resourcesStyles.headline}>
|
||||
<CardTitle title="connectors.title" subtitle="connectors.subtitle" />
|
||||
{isSocial && (
|
||||
<Button
|
||||
|
@ -80,72 +76,74 @@ const Connectors = () => {
|
|||
<TabNavItem href="/connectors">{t('connectors.tab_email_sms')}</TabNavItem>
|
||||
<TabNavItem href="/connectors/social">{t('connectors.tab_social')}</TabNavItem>
|
||||
</TabNav>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table className={classNames(!data && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.connectorName} />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('connectors.connector_name')}</th>
|
||||
<th>{t('connectors.connector_type')}</th>
|
||||
<th>
|
||||
<ConnectorStatusField />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={3}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={3} />}
|
||||
{socialConnectorGroups?.length === 0 && (
|
||||
<TableEmpty
|
||||
columns={3}
|
||||
title={t('connectors.type.social')}
|
||||
content={t('connectors.social_connector_eg')}
|
||||
image={isLightMode ? <SocialConnectorEmpty /> : <SocialConnectorEmptyDark />}
|
||||
>
|
||||
<Button
|
||||
title="connectors.create"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setCreateType(ConnectorType.Social);
|
||||
<div className={resourcesStyles.table}>
|
||||
<div className={tableStyles.scrollable}>
|
||||
<table className={classNames(!data && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.connectorName} />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('connectors.connector_name')}</th>
|
||||
<th>{t('connectors.connector_type')}</th>
|
||||
<th>
|
||||
<ConnectorStatusField />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={3}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={3} />}
|
||||
{socialConnectorGroups?.length === 0 && (
|
||||
<TableEmpty
|
||||
columns={3}
|
||||
title={t('connectors.type.social')}
|
||||
content={t('connectors.social_connector_eg')}
|
||||
image={isLightMode ? <SocialConnectorEmpty /> : <SocialConnectorEmptyDark />}
|
||||
>
|
||||
<Button
|
||||
title="connectors.create"
|
||||
type="outline"
|
||||
onClick={() => {
|
||||
setCreateType(ConnectorType.Social);
|
||||
}}
|
||||
/>
|
||||
</TableEmpty>
|
||||
)}
|
||||
{!isLoading && !isSocial && (
|
||||
<ConnectorRow
|
||||
connectors={smsConnector ? [smsConnector] : []}
|
||||
type={ConnectorType.Sms}
|
||||
onClickSetup={() => {
|
||||
setCreateType(ConnectorType.Sms);
|
||||
}}
|
||||
/>
|
||||
</TableEmpty>
|
||||
)}
|
||||
{!isLoading && !isSocial && (
|
||||
<ConnectorRow
|
||||
connectors={smsConnector ? [smsConnector] : []}
|
||||
type={ConnectorType.Sms}
|
||||
onClickSetup={() => {
|
||||
setCreateType(ConnectorType.Sms);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isSocial && (
|
||||
<ConnectorRow
|
||||
connectors={emailConnector ? [emailConnector] : []}
|
||||
type={ConnectorType.Email}
|
||||
onClickSetup={() => {
|
||||
setCreateType(ConnectorType.Email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{socialConnectorGroups?.map(({ connectors, id }) => (
|
||||
<ConnectorRow key={id} connectors={connectors} type={ConnectorType.Social} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!isLoading && !isSocial && (
|
||||
<ConnectorRow
|
||||
connectors={emailConnector ? [emailConnector] : []}
|
||||
type={ConnectorType.Email}
|
||||
onClickSetup={() => {
|
||||
setCreateType(ConnectorType.Email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{socialConnectorGroups?.map(({ connectors, id }) => (
|
||||
<ConnectorRow key={id} connectors={connectors} type={ConnectorType.Social} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<CreateForm
|
||||
isOpen={Boolean(createType)}
|
||||
type={createType}
|
||||
|
|
44
packages/console/src/pages/Connectors/utils/index.ts
Normal file
44
packages/console/src/pages/Connectors/utils/index.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
|
||||
import type { ConnectorGroup } from '@/types/connector';
|
||||
|
||||
export const getConnectorGroups = <
|
||||
T extends ConnectorResponse | ConnectorFactoryResponse = ConnectorResponse
|
||||
>(
|
||||
connectors: T[]
|
||||
) => {
|
||||
return connectors.reduce<Array<ConnectorGroup<T>>>((previous, item) => {
|
||||
const groupIndex = previous.findIndex(
|
||||
// Only group social connectors
|
||||
({ target }) => target === item.target && item.type === ConnectorType.Social
|
||||
);
|
||||
|
||||
if (groupIndex === -1) {
|
||||
return [
|
||||
...previous,
|
||||
{
|
||||
id: item.id, // Take first connector's id as groupId, only used for indexing.
|
||||
name: item.name,
|
||||
logo: item.logo,
|
||||
logoDark: item.logoDark,
|
||||
description: item.description,
|
||||
target: item.target,
|
||||
type: item.type,
|
||||
connectors: [item],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return previous.map((group, index) => {
|
||||
if (index !== groupIndex) {
|
||||
return group;
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
connectors: [...group.connectors, item],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
};
|
|
@ -31,7 +31,7 @@
|
|||
|
||||
.dropdownTitle {
|
||||
@include _.subhead-cap;
|
||||
padding: _.unit(5) _.unit(5) _.unit(3) _.unit(4);
|
||||
padding: _.unit(3) _.unit(5) _.unit(3) _.unit(4);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
padding: _.unit(4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 554px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
.description {
|
||||
font: var(--font-body-medium);
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
padding-bottom: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.fields {
|
||||
flex: 1;
|
||||
.cardTitle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
>:not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,21 +3,18 @@ import {
|
|||
getDefaultLanguageTag,
|
||||
} from '@logto/phrases';
|
||||
import { AppearanceMode } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
import DetailsForm from '@/components/DetailsForm';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import Select from '@/components/Select';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import type { UserPreferences } from '@/hooks/use-user-preferences';
|
||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
|
||||
import ChangePassword from './components/ChangePassword';
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -49,17 +46,23 @@ const Settings = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Card className={classNames(detailsStyles.container, styles.container)}>
|
||||
<CardTitle title="settings.title" subtitle="settings.description" />
|
||||
<TabNav>
|
||||
<TabNavItem href="/settings">{t('settings.tabs.general')}</TabNavItem>
|
||||
</TabNav>
|
||||
<div className={styles.container}>
|
||||
<CardTitle
|
||||
title="settings.title"
|
||||
subtitle="settings.description"
|
||||
className={styles.cardTitle}
|
||||
/>
|
||||
{isLoading && <div>loading</div>}
|
||||
{error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
|
||||
{isLoaded && (
|
||||
<form className={detailsStyles.body} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
<FormField title="settings.language" className={styles.textField}>
|
||||
<DetailsForm
|
||||
isSubmitting={isSubmitting}
|
||||
isDirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
onDiscard={reset}
|
||||
>
|
||||
<FormCard title="settings.settings">
|
||||
<FormField title="settings.language">
|
||||
<Controller
|
||||
name="language"
|
||||
control={control}
|
||||
|
@ -72,7 +75,7 @@ const Settings = () => {
|
|||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="settings.appearance" className={styles.textField}>
|
||||
<FormField title="settings.appearance">
|
||||
<Controller
|
||||
name="appearanceMode"
|
||||
control={control}
|
||||
|
@ -99,22 +102,11 @@ const Settings = () => {
|
|||
/>
|
||||
</FormField>
|
||||
<ChangePassword />
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
title="general.save_changes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
)}
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue