mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
chore: merge master and fix conflicts
This commit is contained in:
commit
025456c16d
213 changed files with 3737 additions and 3372 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -12,6 +12,7 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
dockerize:
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
@ -79,6 +80,7 @@ jobs:
|
|||
|
||||
|
||||
create-github-release:
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
|
|
|
@ -15,8 +15,6 @@ tasks:
|
|||
pnpm cli db seed
|
||||
pnpm lerna --ignore=@logto/integration-test run --parallel dev
|
||||
env:
|
||||
ALL_YES: 1
|
||||
NO_INQUIRY: 0
|
||||
TRUST_PROXY_HEADER: 1
|
||||
DB_URL: postgres://postgres:p0stgr3s@127.0.0.1:5432
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ const fs = require('fs');
|
|||
const directories = fs.readdirSync('./packages');
|
||||
const reports = directories
|
||||
// Filter out docs temporarily
|
||||
.filter((dir) => ['docs', 'create'].includes(dir))
|
||||
.filter((dir) => !['docs', 'create'].includes(dir))
|
||||
.map((dir) => fs.readFileSync(`./packages/${dir}/report.json`, { encoding: 'utf-8' }));
|
||||
const merged = [];
|
||||
|
||||
|
|
|
@ -48,7 +48,8 @@
|
|||
"pnpm": {
|
||||
"peerDependencyRules": {
|
||||
"allowedVersions": {
|
||||
"react": "^18.0.0"
|
||||
"react": "^18.0.0",
|
||||
"jest": "^29.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
1
packages/cli/.gitignore
vendored
1
packages/cli/.gitignore
vendored
|
@ -1 +1,2 @@
|
|||
alteration-scripts/
|
||||
src/package-json.ts
|
||||
|
|
|
@ -19,7 +19,8 @@
|
|||
},
|
||||
"scripts": {
|
||||
"precommit": "lint-staged",
|
||||
"build": "rimraf lib && tsc -p tsconfig.build.json",
|
||||
"prepare:package-json": "node -p \"'export const packageJson = ' + JSON.stringify(require('./package.json'), undefined, 2) + ';'\" > src/package-json.ts",
|
||||
"build": "rimraf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
|
||||
"start": "node .",
|
||||
"start:dev": "ts-node --files src/index.ts",
|
||||
"lint": "eslint --ext .ts src",
|
||||
|
@ -41,13 +42,13 @@
|
|||
"chalk": "^4.1.2",
|
||||
"decamelize": "^5.0.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"find-up": "^5.0.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"got": "^11.8.2",
|
||||
"hpagent": "^1.0.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",
|
||||
"slonik": "^30.0.0",
|
||||
|
@ -58,18 +59,18 @@
|
|||
"zod": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@silverhand/eslint-config": "1.0.0",
|
||||
"@silverhand/jest-config": "1.0.0",
|
||||
"@silverhand/ts-config": "1.0.0",
|
||||
"@silverhand/eslint-config": "1.2.0",
|
||||
"@silverhand/jest-config": "1.2.2",
|
||||
"@silverhand/ts-config": "1.2.1",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/inquirer": "^8.2.1",
|
||||
"@types/jest": "^28.1.6",
|
||||
"@types/jest": "^29.1.2",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@types/tar": "^6.1.2",
|
||||
"@types/yargs": "^17.0.13",
|
||||
"eslint": "^8.21.0",
|
||||
"jest": "^28.1.3",
|
||||
"jest": "^29.1.2",
|
||||
"lint-staged": "^13.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"rimraf": "^3.0.2",
|
||||
|
@ -78,12 +79,9 @@
|
|||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand",
|
||||
"rules": {
|
||||
"complexity": [
|
||||
"error",
|
||||
7
|
||||
]
|
||||
}
|
||||
"ignorePatterns": [
|
||||
"src/package-json.ts"
|
||||
]
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
}
|
||||
|
|
39
packages/cli/src/commands/connector/add.ts
Normal file
39
packages/cli/src/commands/connector/add.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { CommandModule } from 'yargs';
|
||||
|
||||
import { log } from '../../utilities';
|
||||
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils';
|
||||
|
||||
const add: CommandModule<unknown, { packages: string[]; path?: string; official: boolean }> = {
|
||||
command: ['add [packages...]', 'a', 'install', 'i'],
|
||||
describe: 'Add specific Logto connectors',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('packages', {
|
||||
describe: 'The additional connector package names',
|
||||
type: 'string',
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
.option('official', {
|
||||
alias: 'o',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe:
|
||||
'Add all official connectors.\n' +
|
||||
"If it's true, the specified package names will be ignored.",
|
||||
})
|
||||
.option('path', { alias: 'p', type: 'string', describe: 'The path to your Logto instance' }),
|
||||
handler: async ({ packages: packageNames, path, official }) => {
|
||||
const instancePath = await inquireInstancePath(path);
|
||||
|
||||
if (official) {
|
||||
await addOfficialConnectors(instancePath);
|
||||
}
|
||||
|
||||
await addConnectors(instancePath, packageNames);
|
||||
|
||||
log.info('Restart your Logto instance to get the changes reflected.');
|
||||
},
|
||||
};
|
||||
|
||||
export default add;
|
13
packages/cli/src/commands/connector/index.ts
Normal file
13
packages/cli/src/commands/connector/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import { CommandModule } from 'yargs';
|
||||
|
||||
import add from './add';
|
||||
|
||||
const connector: CommandModule = {
|
||||
command: ['connector', 'c'],
|
||||
describe: 'Command for Logto connectors',
|
||||
builder: (yargs) => yargs.command(add).demandCommand(1),
|
||||
handler: noop,
|
||||
};
|
||||
|
||||
export default connector;
|
172
packages/cli/src/commands/connector/utils.ts
Normal file
172
packages/cli/src/commands/connector/utils.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { exec } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { readFile, mkdir, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { ensureDir, remove } 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 { log, oraPromise } from '../../utilities';
|
||||
import { defaultPath } from '../install/utils';
|
||||
|
||||
const coreDirectory = 'packages/core';
|
||||
const execPromise = promisify(exec);
|
||||
export const npmPackResultGuard = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
filename: z.string(),
|
||||
})
|
||||
.array();
|
||||
|
||||
const buildPathErrorMessage = (value: string) =>
|
||||
`The path ${chalk.green(value)} does not contain a Logto instance, please try another.`;
|
||||
|
||||
const validatePath = async (value: string) => {
|
||||
const corePackageJsonPath = path.resolve(path.join(value, coreDirectory, 'package.json'));
|
||||
|
||||
if (!existsSync(corePackageJsonPath)) {
|
||||
return buildPathErrorMessage(value);
|
||||
}
|
||||
|
||||
const packageJson = await readFile(corePackageJsonPath, { encoding: 'utf8' });
|
||||
const packageName = await z
|
||||
.object({ name: z.string() })
|
||||
.parseAsync(JSON.parse(packageJson))
|
||||
.then(({ name }) => name)
|
||||
.catch(() => '');
|
||||
|
||||
if (packageName !== '@logto/core') {
|
||||
return buildPathErrorMessage(value);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const inquireInstancePath = async (initialPath?: string) => {
|
||||
const { instancePath } = await inquirer.prompt<{ instancePath: string }>(
|
||||
{
|
||||
name: 'instancePath',
|
||||
message: 'Where is your Logto instance?',
|
||||
type: 'input',
|
||||
default: defaultPath,
|
||||
filter: (value: string) => value.trim(),
|
||||
validate: validatePath,
|
||||
},
|
||||
{ instancePath: initialPath }
|
||||
);
|
||||
|
||||
// Validate for initialPath
|
||||
const validated = await validatePath(instancePath);
|
||||
|
||||
if (validated !== true) {
|
||||
log.error(validated);
|
||||
}
|
||||
|
||||
return instancePath;
|
||||
};
|
||||
|
||||
const packagePrefix = 'connector-';
|
||||
|
||||
export const normalizePackageName = (name: string) =>
|
||||
name
|
||||
.split('/')
|
||||
// Prepend prefix to the last fragment if needed
|
||||
.map((fragment, index, array) =>
|
||||
index === array.length - 1 && !fragment.startsWith(packagePrefix) && !fragment.startsWith('@')
|
||||
? packagePrefix + fragment
|
||||
: fragment
|
||||
)
|
||||
.join('/');
|
||||
|
||||
export const addConnectors = async (instancePath: string, packageNames: string[]) => {
|
||||
const cwd = path.join(instancePath, coreDirectory, connectorDirectory);
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
await mkdir(cwd);
|
||||
}
|
||||
|
||||
log.info('Fetch connector metadata');
|
||||
|
||||
const results = await Promise.all(
|
||||
packageNames
|
||||
.map((name) => normalizePackageName(name))
|
||||
.map(async (packageName) => {
|
||||
const run = async () => {
|
||||
const { stdout } = await execPromise(`npm pack ${packageName} --json`, { cwd });
|
||||
const result = npmPackResultGuard.parse(JSON.parse(stdout));
|
||||
|
||||
if (!result[0]) {
|
||||
throw new Error(
|
||||
`Unable to execute ${chalk.green('npm pack')} on package ${chalk.green(packageName)}`
|
||||
);
|
||||
}
|
||||
|
||||
const { filename, name } = result[0];
|
||||
const escapedFilename = filename.replace(/\//g, '-').replace(/@/g, '');
|
||||
const tarPath = path.join(cwd, escapedFilename);
|
||||
const packageDirectory = path.join(cwd, name.replace(/\//g, '-'));
|
||||
|
||||
await remove(packageDirectory);
|
||||
await ensureDir(packageDirectory);
|
||||
await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 });
|
||||
await unlink(tarPath);
|
||||
|
||||
log.succeed(`Added ${chalk.green(name)}`);
|
||||
};
|
||||
|
||||
try {
|
||||
await pRetry(run, { retries: 2 });
|
||||
} catch (error: unknown) {
|
||||
console.warn(`[${packageName}]`, error);
|
||||
|
||||
return packageName;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const errorPackages = results.filter(Boolean);
|
||||
const errorCount = errorPackages.length;
|
||||
|
||||
log.info(
|
||||
errorCount
|
||||
? `Finished with ${errorCount} error${conditionalString(errorCount > 1 && 's')}.`
|
||||
: 'Finished'
|
||||
);
|
||||
|
||||
if (errorCount) {
|
||||
log.warn('Failed to add ' + errorPackages.map((name) => chalk.green(name)).join(', '));
|
||||
}
|
||||
};
|
||||
|
||||
const officialConnectorPrefix = '@logto/connector-';
|
||||
|
||||
const fetchOfficialConnectorList = async () => {
|
||||
const { stdout } = await execPromise(`npm search ${officialConnectorPrefix} --json`);
|
||||
const packages = z
|
||||
.object({ name: z.string() })
|
||||
.transform(({ name }) => name)
|
||||
.array()
|
||||
.parse(JSON.parse(stdout));
|
||||
|
||||
return packages.filter((name) =>
|
||||
['mock', 'kit'].every(
|
||||
(excluded) => !name.slice(officialConnectorPrefix.length).startsWith(excluded)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const addOfficialConnectors = async (instancePath: string) => {
|
||||
const packages = await oraPromise(fetchOfficialConnectorList(), {
|
||||
text: 'Fetch official connector list',
|
||||
prefixText: chalk.blue('[info]'),
|
||||
});
|
||||
await addConnectors(instancePath, packages);
|
||||
};
|
|
@ -1,40 +0,0 @@
|
|||
import { createMockPool } from 'slonik';
|
||||
|
||||
import * as queries from '../../queries/logto-config';
|
||||
import { QueryType } from '../../test-utilities';
|
||||
import * as functions from './alteration';
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const files = Object.freeze([
|
||||
{ filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' },
|
||||
{ filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' },
|
||||
{ filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' },
|
||||
]);
|
||||
|
||||
describe('getUndeployedAlterations()', () => {
|
||||
beforeEach(() => {
|
||||
// `getAlterationFiles()` will ensure the order
|
||||
jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]);
|
||||
});
|
||||
|
||||
it('returns all files if database timestamp is 0', async () => {
|
||||
jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files);
|
||||
});
|
||||
|
||||
it('returns files whose timestamp is greater then database timestamp', async () => {
|
||||
jest
|
||||
.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp')
|
||||
.mockResolvedValueOnce(1_663_923_770);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
|
||||
});
|
||||
});
|
81
packages/cli/src/commands/database/alteration/index.test.ts
Normal file
81
packages/cli/src/commands/database/alteration/index.test.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { createMockPool } from 'slonik';
|
||||
|
||||
import * as functions from '.';
|
||||
import * as queries from '../../../queries/logto-config';
|
||||
import { QueryType } from '../../../test-utilities';
|
||||
import { chooseAlterationsByVersion } from './version';
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
describe('getUndeployedAlterations()', () => {
|
||||
const files = Object.freeze([
|
||||
{ filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' },
|
||||
{ filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' },
|
||||
{ filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
// `getAlterationFiles()` will ensure the order
|
||||
jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]);
|
||||
});
|
||||
|
||||
it('returns all files if database timestamp is 0', async () => {
|
||||
jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files);
|
||||
});
|
||||
|
||||
it('returns files whose timestamp is greater then database timestamp', async () => {
|
||||
jest
|
||||
.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp')
|
||||
.mockResolvedValueOnce(1_663_923_770);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseAlterationsByVersion()', () => {
|
||||
const files = Object.freeze(
|
||||
[
|
||||
'1.0.0_beta.9-1663923770-a.js',
|
||||
'1.0.0_beta.9-1663923771-b.js',
|
||||
'1.0.0_beta.10-1663923772-c.js',
|
||||
'1.0.0_beta.11-1663923773-c.js',
|
||||
'1.0.0_beta.11-1663923774-c.js',
|
||||
'1.0.0-1663923775-c.js',
|
||||
'1.0.0-1663923776-c.js',
|
||||
'1.0.1-1663923777-c.js',
|
||||
'1.2.0-1663923778-c.js',
|
||||
'next-1663923778-c.js',
|
||||
'next-1663923779-c.js',
|
||||
'next-1663923780-c.js',
|
||||
'next1-1663923781-c.js',
|
||||
].map((filename) => ({ filename, path: '/alterations/' + filename }))
|
||||
);
|
||||
|
||||
it('chooses nothing when input version is invalid', async () => {
|
||||
await expect(chooseAlterationsByVersion(files, 'next1')).rejects.toThrow(
|
||||
'Invalid Version: next1'
|
||||
);
|
||||
await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow('Invalid Version: ok');
|
||||
});
|
||||
|
||||
it('chooses correct alteration files', async () => {
|
||||
await Promise.all([
|
||||
expect(chooseAlterationsByVersion([], 'v1.0.0')).resolves.toEqual([]),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.0.0')).resolves.toEqual(files.slice(0, 7)),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.0.0-beta.10')).resolves.toEqual(
|
||||
files.slice(0, 3)
|
||||
),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.1.0')).resolves.toEqual(files.slice(0, 8)),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.2.0')).resolves.toEqual(files.slice(0, 9)),
|
||||
expect(chooseAlterationsByVersion(files, 'next')).resolves.toEqual(files.slice(0, 11)),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -1,27 +1,29 @@
|
|||
import path from 'path';
|
||||
|
||||
import { AlterationScript } from '@logto/schemas/lib/types/alteration';
|
||||
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||
import { findPackage } from '@logto/shared';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import findUp, { exists } from 'find-up';
|
||||
import { copy, existsSync, remove, readdir } from 'fs-extra';
|
||||
import { DatabasePool } from 'slonik';
|
||||
import { CommandModule } from 'yargs';
|
||||
|
||||
import { createPoolFromConfig } from '../../database';
|
||||
import { createPoolFromConfig } from '../../../database';
|
||||
import {
|
||||
getCurrentDatabaseAlterationTimestamp,
|
||||
updateDatabaseTimestamp,
|
||||
} from '../../queries/logto-config';
|
||||
import { getPathInModule, log } from '../../utilities';
|
||||
} from '../../../queries/logto-config';
|
||||
import { getPathInModule, log } from '../../../utilities';
|
||||
import { AlterationFile } from './type';
|
||||
import { chooseAlterationsByVersion } from './version';
|
||||
|
||||
const alterationFileNameRegex = /-(\d+)-?.*\.js$/;
|
||||
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
|
||||
|
||||
const getTimestampFromFileName = (fileName: string) => {
|
||||
const match = alterationFileNameRegex.exec(fileName);
|
||||
const getTimestampFromFilename = (filename: string) => {
|
||||
const match = alterationFilenameRegex.exec(filename);
|
||||
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Can not get timestamp: ${fileName}`);
|
||||
throw new Error(`Can not get timestamp: ${filename}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
|
@ -35,8 +37,6 @@ const importAlterationScript = async (filePath: string): Promise<AlterationScrip
|
|||
return module.default as AlterationScript;
|
||||
};
|
||||
|
||||
type AlterationFile = { path: string; filename: string };
|
||||
|
||||
export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
|
||||
const alterationDirectory = getPathInModule('@logto/schemas', 'alterations');
|
||||
|
||||
|
@ -45,18 +45,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 findUp(
|
||||
async (directory) => {
|
||||
const hasPackageJson = await exists(path.join(directory, 'package.json'));
|
||||
|
||||
return conditional(hasPackageJson && directory);
|
||||
},
|
||||
{
|
||||
// Until we migrate to ESM
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
cwd: __dirname,
|
||||
type: 'directory',
|
||||
}
|
||||
const packageDirectory = await findPackage(
|
||||
// Until we migrate to ESM
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
__dirname
|
||||
);
|
||||
|
||||
const localAlterationDirectory = path.resolve(
|
||||
|
@ -75,11 +67,11 @@ export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
|
|||
await copy(alterationDirectory, localAlterationDirectory);
|
||||
|
||||
const directory = await readdir(localAlterationDirectory);
|
||||
const files = directory.filter((file) => alterationFileNameRegex.test(file));
|
||||
const files = directory.filter((file) => alterationFilenameRegex.test(file));
|
||||
|
||||
return files
|
||||
.slice()
|
||||
.sort((file1, file2) => getTimestampFromFileName(file1) - getTimestampFromFileName(file2))
|
||||
.sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2))
|
||||
.map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename }));
|
||||
};
|
||||
|
||||
|
@ -91,14 +83,14 @@ export const getLatestAlterationTimestamp = async () => {
|
|||
return 0;
|
||||
}
|
||||
|
||||
return getTimestampFromFileName(lastFile.filename);
|
||||
return getTimestampFromFilename(lastFile.filename);
|
||||
};
|
||||
|
||||
export const getUndeployedAlterations = async (pool: DatabasePool) => {
|
||||
const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool);
|
||||
const files = await getAlterationFiles();
|
||||
|
||||
return files.filter(({ filename }) => getTimestampFromFileName(filename) > databaseTimestamp);
|
||||
return files.filter(({ filename }) => getTimestampFromFilename(filename) > databaseTimestamp);
|
||||
};
|
||||
|
||||
const deployAlteration = async (
|
||||
|
@ -110,7 +102,7 @@ const deployAlteration = async (
|
|||
try {
|
||||
await pool.transaction(async (connection) => {
|
||||
await up(connection);
|
||||
await updateDatabaseTimestamp(connection, getTimestampFromFileName(filename));
|
||||
await updateDatabaseTimestamp(connection, getTimestampFromFilename(filename));
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
@ -126,22 +118,30 @@ const deployAlteration = async (
|
|||
log.info(`Run alteration ${filename} succeeded`);
|
||||
};
|
||||
|
||||
const alteration: CommandModule<unknown, { action: string }> = {
|
||||
command: ['alteration <action>', 'alt', 'alter'],
|
||||
const alteration: CommandModule<unknown, { action: string; target?: string }> = {
|
||||
command: ['alteration <action> [target]', 'alt', 'alter'],
|
||||
describe: 'Perform database alteration',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('action', {
|
||||
describe: 'The action to perform, now it only accepts `deploy`',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async ({ action }) => {
|
||||
yargs
|
||||
.positional('action', {
|
||||
describe: 'The action to perform, now it only accepts `deploy`',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('target', {
|
||||
describe: 'The target Logto version for alteration',
|
||||
type: 'string',
|
||||
}),
|
||||
handler: async ({ action, target }) => {
|
||||
if (action !== 'deploy') {
|
||||
log.error('Unsupported action');
|
||||
}
|
||||
|
||||
const pool = await createPoolFromConfig();
|
||||
const alterations = await getUndeployedAlterations(pool);
|
||||
const alterations = await chooseAlterationsByVersion(
|
||||
await getUndeployedAlterations(pool),
|
||||
target
|
||||
);
|
||||
|
||||
log.info(
|
||||
`Found ${alterations.length} alteration${conditionalString(
|
1
packages/cli/src/commands/database/alteration/type.ts
Normal file
1
packages/cli/src/commands/database/alteration/type.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type AlterationFile = { path: string; filename: string };
|
76
packages/cli/src/commands/database/alteration/version.ts
Normal file
76
packages/cli/src/commands/database/alteration/version.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { SemVer, compare, eq, gt } from 'semver';
|
||||
|
||||
import { findLastIndex, log } from '../../../utilities';
|
||||
import { AlterationFile } from './type';
|
||||
|
||||
const getVersionFromFilename = (filename: string) => {
|
||||
try {
|
||||
return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const latestTag = 'latest';
|
||||
const nextTag = 'next';
|
||||
|
||||
export const chooseAlterationsByVersion = async (
|
||||
alterations: readonly AlterationFile[],
|
||||
initialVersion?: string
|
||||
) => {
|
||||
if (initialVersion === nextTag) {
|
||||
const endIndex = findLastIndex(
|
||||
alterations,
|
||||
({ filename }) =>
|
||||
filename.startsWith(nextTag + '-') || Boolean(getVersionFromFilename(filename))
|
||||
);
|
||||
|
||||
if (endIndex === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
log.info(`Deploy target ${chalk.green(nextTag)}`);
|
||||
|
||||
return alterations.slice(0, endIndex);
|
||||
}
|
||||
|
||||
const versions = alterations
|
||||
.map(({ filename }) => getVersionFromFilename(filename))
|
||||
.filter((version): version is SemVer => version instanceof SemVer)
|
||||
// Cannot use `Set` to deduplicate since it's a class
|
||||
.filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
|
||||
.slice()
|
||||
.sort((i, j) => compare(j, i));
|
||||
const initialSemVersion = conditional(initialVersion && new SemVer(initialVersion));
|
||||
|
||||
if (!versions[0]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { version: targetVersion } =
|
||||
initialVersion === latestTag
|
||||
? { version: versions[0] }
|
||||
: await inquirer.prompt<{ version: SemVer }>(
|
||||
{
|
||||
type: 'list',
|
||||
message: 'Choose the alteration target version',
|
||||
name: 'version',
|
||||
choices: versions.map((semVersion) => ({
|
||||
name: semVersion.version,
|
||||
value: semVersion,
|
||||
})),
|
||||
},
|
||||
{
|
||||
version: initialSemVersion,
|
||||
}
|
||||
);
|
||||
|
||||
log.info(`Deploy target ${chalk.green(targetVersion.version)}`);
|
||||
|
||||
return alterations.filter(({ filename }) => {
|
||||
const version = getVersionFromFilename(filename);
|
||||
|
||||
return version && !gt(version, targetVersion);
|
||||
});
|
||||
};
|
|
@ -3,6 +3,7 @@ import { CommandModule } from 'yargs';
|
|||
|
||||
import { getDatabaseUrlFromConfig } from '../../database';
|
||||
import { log } from '../../utilities';
|
||||
import { addOfficialConnectors } from '../connector/utils';
|
||||
import {
|
||||
validateNodeVersion,
|
||||
inquireInstancePath,
|
||||
|
@ -12,14 +13,16 @@ import {
|
|||
createEnv,
|
||||
logFinale,
|
||||
decompress,
|
||||
inquireOfficialConnectors,
|
||||
} from './utils';
|
||||
|
||||
export type InstallArgs = {
|
||||
path?: string;
|
||||
skipSeed: boolean;
|
||||
officialConnectors?: boolean;
|
||||
};
|
||||
|
||||
const installLogto = async ({ path, skipSeed }: InstallArgs) => {
|
||||
const installLogto = async ({ path, skipSeed, officialConnectors }: InstallArgs) => {
|
||||
validateNodeVersion();
|
||||
|
||||
// Get instance path
|
||||
|
@ -34,7 +37,11 @@ const installLogto = async ({ path, skipSeed }: InstallArgs) => {
|
|||
|
||||
// Seed database
|
||||
if (skipSeed) {
|
||||
log.info(`You can use ${chalk.green('db seed')} command to seed database when ready.`);
|
||||
log.info(
|
||||
`Skipped database seeding.\n\n' + ' You can use the ${chalk.green(
|
||||
'db seed'
|
||||
)} command to seed database when ready.\n`
|
||||
);
|
||||
} else {
|
||||
await seedDatabase(instancePath);
|
||||
}
|
||||
|
@ -42,29 +49,51 @@ const installLogto = async ({ path, skipSeed }: InstallArgs) => {
|
|||
// Save to dot env
|
||||
await createEnv(instancePath, await getDatabaseUrlFromConfig());
|
||||
|
||||
// Add official connectors
|
||||
if (await inquireOfficialConnectors(officialConnectors)) {
|
||||
await addOfficialConnectors(instancePath);
|
||||
} else {
|
||||
log.info(
|
||||
'Skipped adding official connectors.\n\n' +
|
||||
` You can use the ${chalk.green('connector add')} command to add connectors at any time.\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Finale
|
||||
logFinale(instancePath);
|
||||
};
|
||||
|
||||
const install: CommandModule<unknown, { path?: string; skipSeed: boolean }> = {
|
||||
const install: CommandModule<
|
||||
unknown,
|
||||
{
|
||||
p?: string;
|
||||
ss: boolean;
|
||||
oc?: boolean;
|
||||
}
|
||||
> = {
|
||||
command: ['init', 'i', 'install'],
|
||||
describe: 'Download and run the latest Logto release',
|
||||
builder: (yargs) =>
|
||||
yargs.options({
|
||||
path: {
|
||||
alias: 'p',
|
||||
p: {
|
||||
alias: 'path',
|
||||
describe: 'Path of Logto, must be a non-existing path',
|
||||
type: 'string',
|
||||
},
|
||||
skipSeed: {
|
||||
alias: 'ss',
|
||||
ss: {
|
||||
alias: 'skip-seed',
|
||||
describe: 'Skip Logto database seeding',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
oc: {
|
||||
alias: 'official-connectors',
|
||||
describe: 'Add official connectors after downloading Logto',
|
||||
type: 'boolean',
|
||||
},
|
||||
}),
|
||||
handler: async ({ path, skipSeed }) => {
|
||||
await installLogto({ path, skipSeed });
|
||||
handler: async ({ p, ss, oc }) => {
|
||||
await installLogto({ path: p, skipSeed: ss, officialConnectors: oc });
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -152,3 +152,17 @@ export const logFinale = (instancePath: string) => {
|
|||
`Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}`
|
||||
);
|
||||
};
|
||||
|
||||
export const inquireOfficialConnectors = async (initialAnswer?: boolean) => {
|
||||
const { value } = await inquirer.prompt<{ value: boolean }>(
|
||||
{
|
||||
name: 'value',
|
||||
message: 'Do you want to add official connectors?',
|
||||
type: 'confirm',
|
||||
default: true,
|
||||
},
|
||||
{ value: initialAnswer }
|
||||
);
|
||||
|
||||
return value;
|
||||
};
|
||||
|
|
1
packages/cli/src/constants.ts
Normal file
1
packages/cli/src/constants.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const connectorDirectory = 'connectors';
|
|
@ -3,22 +3,38 @@ 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';
|
||||
|
||||
void yargs(hideBin(process.argv))
|
||||
.version(false)
|
||||
.option('env', {
|
||||
alias: ['e', 'env-file'],
|
||||
describe: 'The path to your `.env` file',
|
||||
type: 'string',
|
||||
})
|
||||
.option('databaseUrl', {
|
||||
alias: ['db-url'],
|
||||
.option('db', {
|
||||
alias: ['db-url', 'database-url'],
|
||||
describe: 'The Postgres URL to Logto database',
|
||||
type: 'string',
|
||||
})
|
||||
.middleware(({ env, databaseUrl }) => {
|
||||
.option('version', {
|
||||
alias: 'v',
|
||||
describe: 'Print Logto CLI version',
|
||||
type: 'boolean',
|
||||
global: false,
|
||||
})
|
||||
.middleware(({ version }) => {
|
||||
if (version) {
|
||||
console.log(packageJson.name + ' v' + packageJson.version);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
}, true)
|
||||
.middleware(({ env, db: databaseUrl }) => {
|
||||
dotenv.config({ path: env });
|
||||
|
||||
const initialDatabaseUrl = databaseUrl ?? process.env[ConfigKey.DatabaseUrl];
|
||||
|
@ -29,6 +45,7 @@ void yargs(hideBin(process.argv))
|
|||
})
|
||||
.command(install)
|
||||
.command(database)
|
||||
.command(connector)
|
||||
.demandCommand(1)
|
||||
.showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`)
|
||||
.strict()
|
||||
|
|
|
@ -152,3 +152,30 @@ export const getCliConfigWithPrompt = async ({
|
|||
|
||||
return input;
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/a/53187807/12514940
|
||||
/**
|
||||
* Returns the index of the last element in the array where predicate is true, and -1
|
||||
* otherwise.
|
||||
* @param array The source array to search in
|
||||
* @param predicate find calls predicate once for each element of the array, in descending
|
||||
* order, until it finds one where predicate returns true. If such an element is found,
|
||||
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
|
||||
*/
|
||||
export function findLastIndex<T>(
|
||||
array: readonly T[],
|
||||
predicate: (value: T, index: number, object: readonly T[]) => boolean
|
||||
): number {
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let { length } = array;
|
||||
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
while (length--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (predicate(array[length]!, length, array)) {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
"outDir": "lib",
|
||||
"declaration": true,
|
||||
"module": "node16",
|
||||
"target": "es2022"
|
||||
"target": "es2022",
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/roboto-mono": "^4.5.7",
|
||||
"@logto/core-kit": "1.0.0-beta.16",
|
||||
"@logto/core-kit": "1.0.0-beta.18",
|
||||
"@logto/language-kit": "1.0.0-beta.16",
|
||||
"@logto/phrases": "^1.0.0-beta.10",
|
||||
"@logto/phrases-ui": "^1.0.0-beta.10",
|
||||
|
@ -29,11 +29,11 @@
|
|||
"@parcel/transformer-mdx": "2.7.0",
|
||||
"@parcel/transformer-sass": "2.7.0",
|
||||
"@parcel/transformer-svg-react": "2.7.0",
|
||||
"@silverhand/eslint-config": "1.0.0",
|
||||
"@silverhand/eslint-config-react": "1.1.0",
|
||||
"@silverhand/eslint-config": "1.2.0",
|
||||
"@silverhand/eslint-config-react": "1.2.1",
|
||||
"@silverhand/essentials": "^1.3.0",
|
||||
"@silverhand/ts-config": "1.0.0",
|
||||
"@silverhand/ts-config-react": "1.1.0",
|
||||
"@silverhand/ts-config": "1.2.1",
|
||||
"@silverhand/ts-config-react": "1.2.1",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/lodash.kebabcase": "^4.1.6",
|
||||
|
@ -91,8 +91,7 @@
|
|||
"eslintConfig": {
|
||||
"extends": "@silverhand/react",
|
||||
"rules": {
|
||||
"complexity": "off",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "off"
|
||||
"complexity": "off"
|
||||
}
|
||||
},
|
||||
"stylelint": {
|
||||
|
|
7
packages/console/src/assets/images/clear.svg
Normal file
7
packages/console/src/assets/images/clear.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.48984 7.00451C2.65057 6.04014 3.48495 5.33331 4.46263 5.33331L11.537 5.33331C12.5146 5.33331 13.349 6.04014 13.5097 7.00452L14.3986 12.3378C14.6018 13.5569 13.6617 14.6666 12.4258 14.6666H3.57374C2.33786 14.6666 1.39777 13.5569 1.60095 12.3378L2.48984 7.00451ZM4.46263 6.66665C4.13673 6.66665 3.85861 6.90225 3.80503 7.22371L2.91614 12.557C2.84842 12.9634 3.16178 13.3333 3.57374 13.3333H12.4258C12.8378 13.3333 13.1512 12.9634 13.0834 12.557L12.1945 7.22371C12.141 6.90225 11.8628 6.66665 11.537 6.66665H4.46263Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 3.33331C6 2.22874 6.89543 1.33331 8 1.33331C9.10457 1.33331 10 2.22874 10 3.33331V5.33331H8.66667V3.33331C8.66667 2.96512 8.36819 2.66665 8 2.66665C7.63181 2.66665 7.33333 2.96512 7.33333 3.33331V5.33331H6V3.33331Z" fill="currentColor"/>
|
||||
<path d="M4 12.0002C4 11.632 4.29848 11.3336 4.66667 11.3336C5.03486 11.3336 5.33333 11.632 5.33333 12.0002V13.3336C5.33333 13.7017 5.03486 14.0002 4.66667 14.0002C4.29848 14.0002 4 13.7017 4 13.3336V12.0002Z" fill="currentColor"/>
|
||||
<path d="M6.6665 12C6.6665 11.6318 6.96498 11.3333 7.33317 11.3333C7.70136 11.3333 7.99984 11.6318 7.99984 12V13.3333C7.99984 13.7015 7.70136 14 7.33317 14C6.96498 14 6.6665 13.7015 6.6665 13.3333V12Z" fill="currentColor"/>
|
||||
<rect x="2.6665" y="8" width="10.6667" height="1.33333" rx="0.666667" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -16,7 +16,7 @@
|
|||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
|
|
@ -38,7 +38,7 @@ const Alert = ({
|
|||
)}
|
||||
{action && onClick && (
|
||||
<div className={styles.action}>
|
||||
<Button title={action} type="plain" onClick={onClick} />
|
||||
<Button title={action} type="text" size="small" onClick={onClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
.description {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
.role {
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -58,7 +58,7 @@
|
|||
}
|
||||
|
||||
.signOutIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
|
|
|
@ -17,7 +17,7 @@ const ApplicationName = ({ applicationId, isLink = false }: Props) => {
|
|||
const { data } = useSWR<Application>(!isAdminConsole && `/api/applications/${applicationId}`);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const name = (isAdminConsole ? <>Admin Console ({t('system_app')})</> : data?.name) || '-';
|
||||
const name = (isAdminConsole ? <>Admin Console ({t('system_app')})</> : data?.name) ?? '-';
|
||||
|
||||
if (isLink && !isAdminConsole) {
|
||||
return (
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
align-items: center;
|
||||
|
||||
.title {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-medium);
|
||||
}
|
||||
|
||||
|
|
|
@ -53,9 +53,8 @@
|
|||
height: 30px;
|
||||
padding: 0 _.unit(3);
|
||||
|
||||
&.plain {
|
||||
&.text {
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,9 +62,9 @@
|
|||
height: 36px;
|
||||
padding: 0 _.unit(4);
|
||||
|
||||
&.plain {
|
||||
&.text {
|
||||
font: var(--font-subhead-1);
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,16 +72,17 @@
|
|||
height: 44px;
|
||||
padding: 0 _.unit(6);
|
||||
|
||||
&.plain {
|
||||
height: 28px; // same as medium
|
||||
padding: 0;
|
||||
&.text {
|
||||
// same as medium
|
||||
font: var(--font-subhead-1);
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
&.default {
|
||||
background: var(--color-layer-1);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-outline);
|
||||
border-color: var(--color-border);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
|
||||
|
@ -196,11 +196,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.plain {
|
||||
&.text {
|
||||
background: none;
|
||||
border-color: none;
|
||||
font: var(--font-body-medium);
|
||||
font: var(--font-label-large);
|
||||
color: var(--color-text-link);
|
||||
padding: _.unit(0.5) _.unit(1);
|
||||
border-radius: 4px;
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-disabled);
|
||||
|
@ -211,7 +213,7 @@
|
|||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
text-decoration: underline;
|
||||
background-color: var(--color-focused-variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { Ring as Spinner } from '@/components/Spinner';
|
|||
import DangerousRaw from '../DangerousRaw';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type ButtonType = 'primary' | 'danger' | 'outline' | 'plain' | 'default' | 'branding';
|
||||
export type ButtonType = 'primary' | 'danger' | 'outline' | 'text' | 'default' | 'branding';
|
||||
|
||||
type BaseProps = Omit<HTMLProps<HTMLButtonElement>, 'type' | 'size' | 'title'> & {
|
||||
htmlType?: 'button' | 'submit' | 'reset';
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
.subtitle {
|
||||
margin-top: _.unit(1);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&.large {
|
||||
|
|
|
@ -33,6 +33,11 @@
|
|||
|
||||
.copyIcon {
|
||||
margin-left: _.unit(3);
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,11 @@
|
|||
margin-left: _.unit(1);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.required {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
align-items: center;
|
||||
|
||||
> svg {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
|
|
@ -1,18 +1,49 @@
|
|||
import { AdminConsoleKey } from '@logto/phrases';
|
||||
import { Nullable } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { HTMLProps } from 'react';
|
||||
import { ForwardedRef, forwardRef, HTMLProps, useImperativeHandle, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Tooltip from '../Tooltip';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = Omit<HTMLProps<HTMLButtonElement>, 'size' | 'type'> & {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
tooltip?: AdminConsoleKey;
|
||||
};
|
||||
|
||||
const IconButton = ({ size = 'medium', children, className, ...rest }: Props) => {
|
||||
const IconButton = (
|
||||
{ size = 'medium', children, className, tooltip, ...rest }: Props,
|
||||
reference: ForwardedRef<HTMLButtonElement>
|
||||
) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const innerReference = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useImperativeHandle<Nullable<HTMLButtonElement>, Nullable<HTMLButtonElement>>(
|
||||
reference,
|
||||
() => innerReference.current
|
||||
);
|
||||
|
||||
return (
|
||||
<button type="button" className={classNames(styles.button, styles[size], className)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
ref={innerReference}
|
||||
type="button"
|
||||
className={classNames(styles.button, styles[size], className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
anchorRef={innerReference}
|
||||
content={t(tooltip)}
|
||||
horizontalAlign="center"
|
||||
verticalAlign="top"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
export default forwardRef(IconButton);
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
.subtitle {
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-outline);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
h2 {
|
||||
font: var(--font-title-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin: _.unit(6) 0 _.unit(3);
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
margin-bottom: _.unit(6);
|
||||
|
||||
.closeIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
}
|
||||
|
||||
.minusIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.addAnother {
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import { AdminConsoleKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import { KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Minus from '@/assets/images/minus.svg';
|
||||
import * as textButtonStyles from '@/components/TextButton/index.module.scss';
|
||||
import { onKeyDownHandler } from '@/utilities/a11y';
|
||||
|
||||
import Button from '../Button';
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
import IconButton from '../IconButton';
|
||||
import TextInput from '../TextInput';
|
||||
|
@ -86,15 +84,13 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={classNames(textButtonStyles.button, styles.addAnother)}
|
||||
onKeyDown={onKeyDownHandler(handleAdd)}
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
title="general.add_another"
|
||||
className={styles.addAnother}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{t('general.add_another')}
|
||||
</div>
|
||||
/>
|
||||
<ConfirmModal
|
||||
isOpen={deleteFieldIndex !== undefined}
|
||||
confirmButtonText="general.delete"
|
||||
|
|
|
@ -13,6 +13,6 @@
|
|||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ const Search = ({ defaultValue = '', isClearable = false, onSearch, onClearSearc
|
|||
</div>
|
||||
<Button title="general.search" onClick={handleClick} />
|
||||
{isClearable && (
|
||||
<Button size="small" type="plain" title="general.clear_result" onClick={onClearSearch} />
|
||||
<Button size="small" type="text" title="general.clear_result" onClick={onClearSearch} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
.icon {
|
||||
display: flex;
|
||||
margin-left: _.unit(3);
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.clear {
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
a {
|
||||
display: inline-block;
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding-bottom: _.unit(1);
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text-link);
|
||||
padding: _.unit(0.5) _.unit(1);
|
||||
border-radius: _.unit(1);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-focused-variant);
|
||||
}
|
||||
}
|
|
@ -36,7 +36,7 @@
|
|||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-placeholder);
|
||||
}
|
||||
|
||||
// Overwrite webkit auto-fill style
|
||||
|
@ -51,7 +51,7 @@
|
|||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
background-image: none;
|
||||
background-color: var(--color-icon);
|
||||
background-color: var(--color-text-secondary);
|
||||
mask-image: url('../../assets/images/calendar.png');
|
||||
mask-size: 20px 20px;
|
||||
width: 16px;
|
||||
|
@ -62,7 +62,7 @@
|
|||
|
||||
&.disabled {
|
||||
background: var(--color-inverse-on-surface);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
|
|
29
packages/console/src/components/Textarea/index.module.scss
Normal file
29
packages/console/src/components/Textarea/index.module.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
outline: 3px solid transparent;
|
||||
padding: _.unit(2) _.unit(3);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
outline-color: var(--color-focused-variant);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--color-text);
|
||||
font: var(--font-body-medium);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-caption);
|
||||
}
|
||||
}
|
||||
}
|
18
packages/console/src/components/Textarea/index.tsx
Normal file
18
packages/console/src/components/Textarea/index.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import classNames from 'classnames';
|
||||
import { ForwardedRef, forwardRef, HTMLProps } from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = HTMLProps<HTMLTextAreaElement> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Textarea = ({ className, ...rest }: Props, reference: ForwardedRef<HTMLTextAreaElement>) => {
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<textarea {...rest} ref={reference} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(Textarea);
|
|
@ -46,11 +46,6 @@
|
|||
}
|
||||
|
||||
.content {
|
||||
// https://css-tricks.com/almanac/properties/l/line-clamp/
|
||||
/* stylelint-disable-next-line value-no-vendor-prefix */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@include _.multi-line-ellipsis(6);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
cursor: move;
|
||||
|
||||
.draggableIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@
|
|||
|
||||
.footer {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: _.unit(2);
|
||||
|
||||
a {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
.userId {
|
||||
font: var(--body-small);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ const UserName = ({ userId, isLink = false }: Props) => {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const isLoading = !data && !error;
|
||||
const name = data?.name || t('users.unnamed');
|
||||
const name = data?.name ?? t('users.unnamed');
|
||||
|
||||
const isAdmin = data?.roleNames.includes(UserRole.Admin);
|
||||
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from './logs';
|
|||
|
||||
export const themeStorageKey = 'logto:admin_console:theme';
|
||||
export const requestTimeout = 20_000;
|
||||
export const generatedPasswordStorageKey = 'logto:admin_console:generated_password';
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { LanguageTag } from '@logto/language-kit';
|
||||
import { builtInLanguages as builtInUiLanguages } from '@logto/phrases-ui';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { CustomPhraseResponse } from '@/types/custom-phrase';
|
||||
|
||||
import { RequestError } from './use-api';
|
||||
import useApi, { RequestError } from './use-api';
|
||||
|
||||
const useUiLanguages = () => {
|
||||
const {
|
||||
|
@ -26,11 +27,22 @@ const useUiLanguages = () => {
|
|||
[customPhraseList]
|
||||
);
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const addLanguage = useCallback(
|
||||
async (languageTag: LanguageTag) => {
|
||||
await api.put(`/api/custom-phrases/${languageTag}`, { json: {} });
|
||||
await mutate();
|
||||
},
|
||||
[api, mutate]
|
||||
);
|
||||
|
||||
return {
|
||||
customPhrases: customPhraseList,
|
||||
languages,
|
||||
error,
|
||||
isLoading: !customPhraseList && !error,
|
||||
addLanguage,
|
||||
mutate,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
padding: _.unit(4);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-subhead-2);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
cursor: pointer;
|
||||
|
||||
> svg {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.index {
|
||||
|
@ -83,7 +83,7 @@
|
|||
|
||||
h3 {
|
||||
font: var(--font-title-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin: _.unit(6) 0 _.unit(3);
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
margin-right: _.unit(6);
|
||||
padding-bottom: _.unit(1);
|
||||
font: var(--font-subhead-2);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-block-end: unset;
|
||||
padding-inline-start: unset;
|
||||
cursor: pointer;
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
align-items: center;
|
||||
|
||||
.moreIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
> *:not(:first-child) {
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
align-items: center;
|
||||
|
||||
.moreIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
> :not(:first-child) {
|
||||
|
@ -96,7 +96,7 @@
|
|||
|
||||
.text {
|
||||
font: var(--font-subhead-2);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.verticalBar {
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
}
|
||||
|
||||
.closeIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.githubIcon {
|
||||
|
|
|
@ -87,7 +87,7 @@ const GuideHeader = ({ appName, selectedSdk, isCompact = false, onClose }: Props
|
|||
subtitle="applications.guide.header_description"
|
||||
/>
|
||||
<Spacer />
|
||||
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
|
||||
<Button type="text" size="small" title="general.skip" onClick={onClose} />
|
||||
<Button
|
||||
className={styles.getSampleButton}
|
||||
type="outline"
|
||||
|
|
|
@ -19,5 +19,5 @@
|
|||
|
||||
.description {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-medium);
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
font: var(--font-body-medium);
|
||||
|
||||
.label {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-subhead-2);
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
.description {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.successfulTooltip {
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
align-items: center;
|
||||
|
||||
.moreIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
> *:not(:first-child) {
|
||||
|
@ -66,7 +66,7 @@
|
|||
|
||||
.text {
|
||||
font: var(--font-subhead-2);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.verticalBar {
|
||||
|
@ -91,7 +91,7 @@
|
|||
}
|
||||
|
||||
.resetIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.readme {
|
||||
|
|
|
@ -38,15 +38,14 @@
|
|||
.connectorId {
|
||||
margin-top: _.unit(1);
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: _.unit(1);
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
@include _.multi-line-ellipsis(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -127,9 +127,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
|||
<div className={styles.name}>
|
||||
<UnnamedTrans resource={name} />
|
||||
</div>
|
||||
{type !== ConnectorType.Social && (
|
||||
<div className={styles.connectorId}>{id}</div>
|
||||
)}
|
||||
<div className={styles.description}>
|
||||
<UnnamedTrans resource={description} />
|
||||
</div>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
}
|
||||
|
||||
.closeIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
margin-left: _.unit(1);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
> svg {
|
||||
display: block;
|
||||
|
|
|
@ -15,6 +15,6 @@
|
|||
|
||||
.label {
|
||||
font: var(--font-body-small);
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
margin-top: _.unit(1);
|
||||
padding-right: _.unit(6);
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import * as styles from './index.module.scss';
|
|||
import { ActiveUsersResponse, NewUsersResponse, TotalUsersResponse } from './types';
|
||||
|
||||
const tickStyle = {
|
||||
fill: 'var(--color-caption)',
|
||||
fill: 'var(--color-text-secondary)',
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-family)',
|
||||
};
|
||||
|
|
|
@ -20,15 +20,10 @@
|
|||
margin-top: _.unit(1);
|
||||
padding-right: _.unit(6);
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
.hideButton {
|
||||
color: var(--color-text-link);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +51,7 @@
|
|||
|
||||
.subtitle {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import Card from '@/components/Card';
|
|||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import Spacer from '@/components/Spacer';
|
||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||
import { onKeyDownHandler } from '@/utilities/a11y';
|
||||
|
||||
import Skeleton from './components/Skeleton';
|
||||
import useGetStartedMetadata from './hook';
|
||||
|
@ -44,19 +43,13 @@ const GetStarted = () => {
|
|||
<Spacer />
|
||||
<span>
|
||||
{t('get_started.subtitle_part2')}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<Button
|
||||
title="get_started.hide_this"
|
||||
type="text"
|
||||
size="small"
|
||||
className={styles.hideButton}
|
||||
onClick={showConfirmModalHandler}
|
||||
onKeyDown={onKeyDownHandler({
|
||||
Enter: showConfirmModalHandler,
|
||||
' ': showConfirmModalHandler,
|
||||
Esc: hideConfirmModalHandler,
|
||||
})}
|
||||
>
|
||||
{t('get_started.hide_this')}
|
||||
</span>
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -77,7 +77,7 @@ const ColorForm = () => {
|
|||
<div className={styles.darkModeTip}>
|
||||
{t('sign_in_exp.color.dark_mode_reset_tip')}
|
||||
<Button
|
||||
type="plain"
|
||||
type="text"
|
||||
size="small"
|
||||
title="sign_in_exp.color.reset"
|
||||
onClick={handleResetColor}
|
||||
|
|
|
@ -20,6 +20,6 @@
|
|||
height: 16px;
|
||||
object-fit: cover;
|
||||
margin-left: _.unit(1);
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
}
|
||||
|
||||
.closeIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -92,7 +92,7 @@ const GuideModal = ({ isOpen, onClose }: Props) => {
|
|||
<CardTitle size="small" title="sign_in_exp.title" subtitle="sign_in_exp.description" />
|
||||
<Spacer />
|
||||
<Button
|
||||
type="plain"
|
||||
type="text"
|
||||
size="small"
|
||||
title="general.skip"
|
||||
isLoading={isLoading}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { languages as uiLanguageNameMapping } from '@logto/language-kit';
|
||||
import { SignInExperience } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
@ -9,13 +8,11 @@ import useSWR from 'swr';
|
|||
import FormField from '@/components/FormField';
|
||||
import Select from '@/components/Select';
|
||||
import Switch from '@/components/Switch';
|
||||
import * as textButtonStyles from '@/components/TextButton/index.module.scss';
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||
|
||||
import useLanguageEditorContext from '../hooks/use-language-editor-context';
|
||||
import { SignInExperienceForm } from '../types';
|
||||
import ManageLanguageModal from './ManageLanguageModal';
|
||||
import ManageLanguageButton from './ManageLanguage/ManageLanguageButton';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -28,7 +25,6 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
|
|||
const { watch, control, register, setValue } = useFormContext<SignInExperienceForm>();
|
||||
const isAutoDetect = watch('languageInfo.autoDetect');
|
||||
const selectedDefaultLanguage = watch('languageInfo.fallbackLanguage');
|
||||
const [isManageLanguageFormOpen, setIsManageLanguageFormOpen] = useState(false);
|
||||
const { languages } = useUiLanguages();
|
||||
|
||||
const languageOptions = useMemo(() => {
|
||||
|
@ -38,9 +34,6 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
|
|||
}));
|
||||
}, [languages]);
|
||||
|
||||
const { context: languageEditorContext, Provider: LanguageEditorContextProvider } =
|
||||
useLanguageEditorContext(languages);
|
||||
|
||||
useEffect(() => {
|
||||
if (!languages.includes(selectedDefaultLanguage)) {
|
||||
setValue(
|
||||
|
@ -58,19 +51,10 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
|
|||
{...register('languageInfo.autoDetect')}
|
||||
label={t('sign_in_exp.others.languages.description')}
|
||||
/>
|
||||
{isManageLanguageVisible && (
|
||||
<ManageLanguageButton className={styles.manageLanguageButton} />
|
||||
)}
|
||||
</FormField>
|
||||
{isManageLanguageVisible && (
|
||||
// TODO: @yijun
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={classNames(textButtonStyles.button, styles.manageLanguage)}
|
||||
onClick={() => {
|
||||
setIsManageLanguageFormOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('sign_in_exp.others.languages.manage_language')}
|
||||
</div>
|
||||
)}
|
||||
<FormField title="sign_in_exp.others.languages.default_language">
|
||||
<Controller
|
||||
name="languageInfo.fallbackLanguage"
|
||||
|
@ -85,15 +69,6 @@ const LanguagesForm = ({ isManageLanguageVisible = false }: Props) => {
|
|||
: t('sign_in_exp.others.languages.default_language_description_fixed')}
|
||||
</div>
|
||||
</FormField>
|
||||
<LanguageEditorContextProvider value={languageEditorContext}>
|
||||
<ManageLanguageModal
|
||||
isOpen={isManageLanguageFormOpen}
|
||||
languageTags={languages}
|
||||
onClose={() => {
|
||||
setIsManageLanguageFormOpen(false);
|
||||
}}
|
||||
/>
|
||||
</LanguageEditorContextProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,15 +5,12 @@
|
|||
position: relative;
|
||||
|
||||
.addLanguageButton {
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
border-color: var(--color-outline);
|
||||
color: var(--color-text);
|
||||
background: unset;
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
color: var(--color-outline);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,6 +24,8 @@
|
|||
border-radius: 8px;
|
||||
max-height: 288px;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
|
||||
.dropDownItem {
|
||||
width: 100%;
|
||||
|
@ -34,19 +33,18 @@
|
|||
padding: _.unit(2);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font: var(--font-body-medium);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.languageName {
|
||||
font: var(--font-label-large);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.languageTag {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import Plus from '@/assets/images/plus.svg';
|
|||
import SearchIcon from '@/assets/images/search.svg';
|
||||
import Button from '@/components/Button';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import { onKeyDownHandler } from '@/utilities/a11y';
|
||||
|
||||
import * as style from './AddLanguageSelector.module.scss';
|
||||
|
||||
|
@ -54,6 +55,12 @@ const AddLanguageSelector = ({ options, onSelect }: Props) => {
|
|||
};
|
||||
}, [isDropDownOpen, searchInputRef]);
|
||||
|
||||
const handleSelect = (languageTag: LanguageTag) => {
|
||||
onSelect(languageTag);
|
||||
setIsDropDownOpen(false);
|
||||
setSearchInputValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={selectorRef} className={style.languageSelector}>
|
||||
<div className={style.input}>
|
||||
|
@ -61,7 +68,7 @@ const AddLanguageSelector = ({ options, onSelect }: Props) => {
|
|||
className={classNames(style.addLanguageButton, isDropDownOpen && style.hidden)}
|
||||
icon={<Plus className={style.buttonIcon} />}
|
||||
title="sign_in_exp.others.manage_language.add_language"
|
||||
type="outline"
|
||||
type="default"
|
||||
size="medium"
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(true);
|
||||
|
@ -81,15 +88,16 @@ const AddLanguageSelector = ({ options, onSelect }: Props) => {
|
|||
{isDropDownOpen && filteredOptions.length > 0 && (
|
||||
<ul className={style.dropDown}>
|
||||
{filteredOptions.map((languageTag) => (
|
||||
// TODO: @yijun
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<li
|
||||
key={languageTag}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
className={style.dropDownItem}
|
||||
onKeyDown={onKeyDownHandler(() => {
|
||||
handleSelect(languageTag);
|
||||
})}
|
||||
onClick={() => {
|
||||
onSelect(languageTag);
|
||||
setIsDropDownOpen(false);
|
||||
setSearchInputValue('');
|
||||
handleSelect(languageTag);
|
||||
}}
|
||||
>
|
||||
<div className={style.languageName}>{uiLanguageNameMapping[languageTag]}</div>
|
|
@ -0,0 +1,31 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.sectionTitle {
|
||||
@include _.subhead-cap-small;
|
||||
color: var(--color-neutral-variant-60);
|
||||
background-color: var(--color-layer-light);
|
||||
padding: _.unit(1) 0;
|
||||
}
|
||||
|
||||
.sectionDataKey {
|
||||
padding: _.unit(4) _.unit(5);
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sectionBuiltInText {
|
||||
padding: _.unit(2) _.unit(3);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
background: var(--color-layer-2);
|
||||
}
|
||||
|
||||
.inputCell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sectionInputArea {
|
||||
position: absolute;
|
||||
inset: _.unit(2) _.unit(5);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Translation } from '@logto/schemas';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import TextInput from '@/components/TextInput';
|
||||
import Textarea from '@/components/Textarea';
|
||||
|
||||
import * as style from './EditSection.module.scss';
|
||||
|
||||
|
@ -27,10 +27,10 @@ const EditSection = ({ dataKey, data }: EditSectionProps) => {
|
|||
<tr key={fieldKey}>
|
||||
<td className={style.sectionDataKey}>{field}</td>
|
||||
<td>
|
||||
<TextInput readOnly value={value} className={style.sectionBuiltInText} />
|
||||
<div className={style.sectionBuiltInText}>{value}</div>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput {...register(fieldKey)} />
|
||||
<td className={style.inputCell}>
|
||||
<Textarea className={style.sectionInputArea} {...register(fieldKey)} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
|
@ -1,6 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.languageEditor {
|
||||
.languageDetails {
|
||||
flex-grow: 1;
|
||||
|
||||
.title {
|
||||
|
@ -17,7 +17,7 @@
|
|||
> span {
|
||||
margin-left: _.unit(2);
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.builtInFlag {
|
||||
|
@ -32,34 +32,30 @@
|
|||
}
|
||||
|
||||
.content {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
height: 481px;
|
||||
overflow-y: auto;
|
||||
|
||||
> table {
|
||||
border: none;
|
||||
|
||||
> thead > tr {
|
||||
> th {
|
||||
> thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
// Note: cells with `position: relative` style will overlap this sticky header, add a z-index to fix it.
|
||||
z-index: 1;
|
||||
|
||||
tr > th {
|
||||
padding: _.unit(1) _.unit(5);
|
||||
font: var(--font-label-large);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-layer-1);
|
||||
}
|
||||
|
||||
> th:first-child {
|
||||
width: 300px;
|
||||
padding: _.unit(1) _.unit(5);
|
||||
}
|
||||
}
|
||||
|
||||
> tbody > tr {
|
||||
> td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
> td:first-child {
|
||||
padding: _.unit(1) _.unit(5);
|
||||
}
|
||||
> tbody > tr > td {
|
||||
padding: _.unit(2) _.unit(5);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,9 +65,12 @@
|
|||
}
|
||||
|
||||
.clearButton {
|
||||
display: flex;
|
||||
margin-left: _.unit(2);
|
||||
flex-direction: row-reverse;
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
|
||||
.clearIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
|
@ -91,7 +90,7 @@
|
|||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
padding: _.unit(5);
|
|
@ -10,27 +10,30 @@ import { toast } from 'react-hot-toast';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
|
||||
import Clear from '@/assets/images/clear.svg';
|
||||
import Delete from '@/assets/images/delete.svg';
|
||||
import Button from '@/components/Button';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import IconButton from '@/components/IconButton';
|
||||
import useApi, { RequestError } from '@/hooks/use-api';
|
||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||
import { CustomPhraseResponse } from '@/types/custom-phrase';
|
||||
|
||||
import { LanguageEditorContext } from '../../hooks/use-language-editor-context';
|
||||
import { createEmptyUiTranslation, flattenTranslation } from '../../utilities';
|
||||
import { createEmptyUiTranslation, flattenTranslation } from '../../../utilities';
|
||||
import EditSection from './EditSection';
|
||||
import * as style from './LanguageEditor.module.scss';
|
||||
import * as style from './LanguageDetails.module.scss';
|
||||
import { LanguageEditorContext } from './use-language-editor-context';
|
||||
|
||||
const emptyUiTranslation = createEmptyUiTranslation();
|
||||
|
||||
const LanguageEditor = () => {
|
||||
const LanguageDetails = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('/api/sign-in-exp');
|
||||
|
||||
const { languages, selectedLanguage, setIsDirty, setSelectedLanguage, stopAddingLanguage } =
|
||||
useContext(LanguageEditorContext);
|
||||
const { languages } = useUiLanguages();
|
||||
|
||||
const { selectedLanguage, setIsDirty, setSelectedLanguage } = useContext(LanguageEditorContext);
|
||||
|
||||
const [isDeletionAlertOpen, setIsDeletionAlertOpen] = useState(false);
|
||||
|
||||
|
@ -105,32 +108,11 @@ const LanguageEditor = () => {
|
|||
|
||||
void globalMutate('/api/custom-phrases');
|
||||
|
||||
stopAddingLanguage();
|
||||
|
||||
return updatedCustomPhrase;
|
||||
},
|
||||
[api, globalMutate, stopAddingLanguage]
|
||||
[api, globalMutate]
|
||||
);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
if (!customPhrase && !isDefaultLanguage) {
|
||||
stopAddingLanguage(true);
|
||||
setSelectedLanguage(
|
||||
languages.find((languageTag) => languageTag !== selectedLanguage) ?? 'en'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsDeletionAlertOpen(true);
|
||||
}, [
|
||||
customPhrase,
|
||||
isDefaultLanguage,
|
||||
languages,
|
||||
selectedLanguage,
|
||||
setSelectedLanguage,
|
||||
stopAddingLanguage,
|
||||
]);
|
||||
|
||||
const onConfirmDeletion = useCallback(async () => {
|
||||
setIsDeletionAlertOpen(false);
|
||||
|
||||
|
@ -164,7 +146,7 @@ const LanguageEditor = () => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<div className={style.languageEditor}>
|
||||
<div className={style.languageDetails}>
|
||||
<div className={style.title}>
|
||||
<div className={style.languageInfo}>
|
||||
{uiLanguageNameMapping[selectedLanguage]}
|
||||
|
@ -176,7 +158,12 @@ const LanguageEditor = () => {
|
|||
)}
|
||||
</div>
|
||||
{!isBuiltIn && (
|
||||
<IconButton onClick={onDelete}>
|
||||
<IconButton
|
||||
tooltip="sign_in_exp.others.manage_language.deletion_tip"
|
||||
onClick={() => {
|
||||
setIsDeletionAlertOpen(true);
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
)}
|
||||
|
@ -194,15 +181,13 @@ const LanguageEditor = () => {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>{t('sign_in_exp.others.manage_language.key')}</th>
|
||||
<th>{t('sign_in_exp.others.manage_language.logto_source_language')}</th>
|
||||
<th>{t('sign_in_exp.others.manage_language.logto_source_values')}</th>
|
||||
<th>
|
||||
<span className={style.customValuesColumn}>
|
||||
{t('sign_in_exp.others.manage_language.custom_values')}
|
||||
<Button
|
||||
type="plain"
|
||||
title="sign_in_exp.others.manage_language.clear_all"
|
||||
<IconButton
|
||||
className={style.clearButton}
|
||||
icon={<Delete />}
|
||||
tooltip="sign_in_exp.others.manage_language.clear_all_tip"
|
||||
onClick={() => {
|
||||
for (const [key, value] of Object.entries(
|
||||
flattenTranslation(emptyUiTranslation)
|
||||
|
@ -210,7 +195,9 @@ const LanguageEditor = () => {
|
|||
setValue(key, value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Clear className={style.clearIcon} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
|
@ -260,4 +247,4 @@ const LanguageEditor = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default LanguageEditor;
|
||||
export default LanguageDetails;
|
|
@ -7,13 +7,13 @@
|
|||
border-radius: 8px;
|
||||
|
||||
.languageName {
|
||||
font: var(--font-title-medium);
|
||||
color: var(--color-text);
|
||||
font: var(--font-label-large);
|
||||
}
|
||||
|
||||
.languageTag {
|
||||
font: var(--font-label-large);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-medium);
|
||||
}
|
||||
|
||||
&:hover {
|
|
@ -2,6 +2,8 @@ import { languages, LanguageTag } from '@logto/language-kit';
|
|||
import classNames from 'classnames';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { onKeyDownHandler } from '@/utilities/a11y';
|
||||
|
||||
import * as style from './LanguageItem.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -19,18 +21,22 @@ const LanguageItem = ({ languageTag, isSelected, onClick }: Props) => {
|
|||
}
|
||||
}, [isSelected]);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (isSelected) {
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
// TODO: @yijun
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={itemRef}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
className={classNames(style.languageItem, isSelected && style.selected)}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={onKeyDownHandler(handleSelect)}
|
||||
>
|
||||
<div className={style.languageName}>{languages[languageTag]}</div>
|
||||
<div className={style.languageTag}>{languageTag}</div>
|
|
@ -5,7 +5,7 @@
|
|||
padding: _.unit(3) _.unit(2);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--color-layer-light);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-divider);
|
||||
|
||||
.languageItemList {
|
||||
margin-top: _.unit(3);
|
|
@ -5,22 +5,23 @@ import {
|
|||
} from '@logto/language-kit';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { LanguageEditorContext } from '../../hooks/use-language-editor-context';
|
||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||
|
||||
import AddLanguageSelector from './AddLanguageSelector';
|
||||
import LanguageItem from './LanguageItem';
|
||||
import * as style from './LanguageNav.module.scss';
|
||||
import { LanguageEditorContext } from './use-language-editor-context';
|
||||
|
||||
const LanguageNav = () => {
|
||||
const { languages, addLanguage } = useUiLanguages();
|
||||
|
||||
const {
|
||||
languages,
|
||||
selectedLanguage,
|
||||
isAddingLanguage,
|
||||
isDirty,
|
||||
setConfirmationState,
|
||||
setSelectedLanguage,
|
||||
setPreSelectedLanguage,
|
||||
setPreAddedLanguage,
|
||||
startAddingLanguage,
|
||||
} = useContext(LanguageEditorContext);
|
||||
|
||||
const languageOptions = Object.keys(uiLanguageNameMapping).filter(
|
||||
|
@ -28,19 +29,20 @@ const LanguageNav = () => {
|
|||
isLanguageTag(languageTag) && !languages.includes(languageTag)
|
||||
);
|
||||
|
||||
const onAddLanguage = (languageTag: LanguageTag) => {
|
||||
if (isDirty || isAddingLanguage) {
|
||||
const onAddLanguage = async (languageTag: LanguageTag) => {
|
||||
if (isDirty) {
|
||||
setPreAddedLanguage(languageTag);
|
||||
setConfirmationState('try-add-language');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
startAddingLanguage(languageTag);
|
||||
await addLanguage(languageTag);
|
||||
setSelectedLanguage(languageTag);
|
||||
};
|
||||
|
||||
const onSwitchLanguage = (languageTag: LanguageTag) => {
|
||||
if (isDirty || isAddingLanguage) {
|
||||
if (isDirty) {
|
||||
setPreSelectedLanguage(languageTag);
|
||||
setConfirmationState('try-switch-language');
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
|
@ -1,53 +1,55 @@
|
|||
import { LanguageTag } from '@logto/language-kit';
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import ModalLayout from '@/components/ModalLayout';
|
||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import { LanguageEditorContext } from '../../hooks/use-language-editor-context';
|
||||
import LanguageEditor from './LanguageEditor';
|
||||
import LanguageDetails from './LanguageDetails';
|
||||
import LanguageNav from './LanguageNav';
|
||||
import * as style from './index.module.scss';
|
||||
import useLanguageEditorContext, { LanguageEditorContext } from './use-language-editor-context';
|
||||
|
||||
type ManageLanguageModalProps = {
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
languageTags: LanguageTag[];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ManageLanguageModal = ({ isOpen, languageTags, onClose }: ManageLanguageModalProps) => {
|
||||
const LanguageEditorModal = ({ isOpen, onClose }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { languages, addLanguage } = useUiLanguages();
|
||||
|
||||
const defaultSelectedLanguage = languages[0] ?? 'en';
|
||||
|
||||
const {
|
||||
preSelectedLanguage,
|
||||
preAddedLanguage,
|
||||
isAddingLanguage,
|
||||
isDirty,
|
||||
confirmationState,
|
||||
setSelectedLanguage,
|
||||
setPreSelectedLanguage,
|
||||
setPreAddedLanguage,
|
||||
setConfirmationState,
|
||||
startAddingLanguage,
|
||||
stopAddingLanguage,
|
||||
} = useContext(LanguageEditorContext);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedLanguage(defaultSelectedLanguage);
|
||||
}, [defaultSelectedLanguage, setSelectedLanguage]);
|
||||
|
||||
const onCloseModal = () => {
|
||||
if (isAddingLanguage || isDirty) {
|
||||
if (isDirty) {
|
||||
setConfirmationState('try-close');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
setSelectedLanguage(languageTags[0] ?? 'en');
|
||||
setSelectedLanguage(languages[0] ?? 'en');
|
||||
};
|
||||
|
||||
const onConfirmUnsavedChanges = () => {
|
||||
stopAddingLanguage(true);
|
||||
|
||||
const onConfirmUnsavedChanges = async () => {
|
||||
if (confirmationState === 'try-close') {
|
||||
onClose();
|
||||
}
|
||||
|
@ -58,7 +60,9 @@ const ManageLanguageModal = ({ isOpen, languageTags, onClose }: ManageLanguageMo
|
|||
}
|
||||
|
||||
if (confirmationState === 'try-add-language' && preAddedLanguage) {
|
||||
startAddingLanguage(preAddedLanguage);
|
||||
await addLanguage(preAddedLanguage);
|
||||
setSelectedLanguage(preAddedLanguage);
|
||||
setPreAddedLanguage(undefined);
|
||||
}
|
||||
|
||||
setConfirmationState('none');
|
||||
|
@ -74,12 +78,13 @@ const ManageLanguageModal = ({ isOpen, languageTags, onClose }: ManageLanguageMo
|
|||
>
|
||||
<div className={style.container}>
|
||||
<LanguageNav />
|
||||
<LanguageEditor />
|
||||
<LanguageDetails />
|
||||
</div>
|
||||
</ModalLayout>
|
||||
<ConfirmModal
|
||||
isOpen={confirmationState !== 'none'}
|
||||
cancelButtonText="general.stay_on_page"
|
||||
confirmButtonText="general.leave_page"
|
||||
onCancel={() => {
|
||||
setConfirmationState('none');
|
||||
}}
|
||||
|
@ -91,4 +96,15 @@ const ManageLanguageModal = ({ isOpen, languageTags, onClose }: ManageLanguageMo
|
|||
);
|
||||
};
|
||||
|
||||
export default ManageLanguageModal;
|
||||
const LanguageEditor = (props: Props) => {
|
||||
const { context: languageEditorContext, Provider: LanguageEditorContextProvider } =
|
||||
useLanguageEditorContext();
|
||||
|
||||
return (
|
||||
<LanguageEditorContextProvider value={languageEditorContext}>
|
||||
<LanguageEditorModal {...props} />
|
||||
</LanguageEditorContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageEditor;
|
|
@ -1,5 +1,5 @@
|
|||
import { LanguageTag } from '@logto/language-kit';
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useMemo, useState } from 'react';
|
||||
|
||||
const noop = () => {
|
||||
throw new Error('Context provider not found');
|
||||
|
@ -8,11 +8,9 @@ const noop = () => {
|
|||
export type ConfirmationState = 'none' | 'try-close' | 'try-switch-language' | 'try-add-language';
|
||||
|
||||
export type Context = {
|
||||
languages: LanguageTag[];
|
||||
selectedLanguage: LanguageTag;
|
||||
preSelectedLanguage?: LanguageTag;
|
||||
preAddedLanguage?: LanguageTag;
|
||||
isAddingLanguage: boolean;
|
||||
isDirty: boolean;
|
||||
confirmationState: ConfirmationState;
|
||||
setSelectedLanguage: React.Dispatch<React.SetStateAction<LanguageTag>>;
|
||||
|
@ -20,16 +18,12 @@ export type Context = {
|
|||
setPreAddedLanguage: React.Dispatch<React.SetStateAction<LanguageTag | undefined>>;
|
||||
setIsDirty: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setConfirmationState: React.Dispatch<React.SetStateAction<ConfirmationState>>;
|
||||
startAddingLanguage: (languageTag: LanguageTag) => void;
|
||||
stopAddingLanguage: (isCanceled?: boolean) => void;
|
||||
};
|
||||
|
||||
export const LanguageEditorContext = createContext<Context>({
|
||||
languages: [],
|
||||
selectedLanguage: 'en',
|
||||
preSelectedLanguage: undefined,
|
||||
preAddedLanguage: undefined,
|
||||
isAddingLanguage: false,
|
||||
isDirty: false,
|
||||
confirmationState: 'none',
|
||||
setSelectedLanguage: noop,
|
||||
|
@ -37,53 +31,20 @@ export const LanguageEditorContext = createContext<Context>({
|
|||
setPreAddedLanguage: noop,
|
||||
setIsDirty: noop,
|
||||
setConfirmationState: noop,
|
||||
startAddingLanguage: noop,
|
||||
stopAddingLanguage: noop,
|
||||
});
|
||||
|
||||
const useLanguageEditorContext = (defaultLanguages: LanguageTag[]) => {
|
||||
const [languages, setLanguages] = useState(defaultLanguages);
|
||||
|
||||
useEffect(() => {
|
||||
setLanguages(defaultLanguages);
|
||||
}, [defaultLanguages]);
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<LanguageTag>(languages[0] ?? 'en');
|
||||
const useLanguageEditorContext = () => {
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<LanguageTag>('en');
|
||||
const [preSelectedLanguage, setPreSelectedLanguage] = useState<LanguageTag>();
|
||||
const [preAddedLanguage, setPreAddedLanguage] = useState<LanguageTag>();
|
||||
const [isAddingLanguage, setIsAddingLanguage] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [confirmationState, setConfirmationState] = useState<ConfirmationState>('none');
|
||||
|
||||
const startAddingLanguage = useCallback(
|
||||
(language: LanguageTag) => {
|
||||
setLanguages([...new Set([language, ...defaultLanguages])].slice().sort());
|
||||
setSelectedLanguage(language);
|
||||
setIsAddingLanguage(true);
|
||||
},
|
||||
[defaultLanguages]
|
||||
);
|
||||
|
||||
const stopAddingLanguage = useCallback(
|
||||
(isCanceled = false) => {
|
||||
if (isAddingLanguage) {
|
||||
if (isCanceled) {
|
||||
setLanguages(defaultLanguages);
|
||||
setSelectedLanguage(languages[0] ?? 'en');
|
||||
}
|
||||
setIsAddingLanguage(false);
|
||||
}
|
||||
},
|
||||
[defaultLanguages, isAddingLanguage, languages]
|
||||
);
|
||||
|
||||
const context = useMemo<Context>(
|
||||
() => ({
|
||||
languages,
|
||||
selectedLanguage,
|
||||
preSelectedLanguage,
|
||||
preAddedLanguage,
|
||||
isAddingLanguage,
|
||||
isDirty,
|
||||
confirmationState,
|
||||
setSelectedLanguage,
|
||||
|
@ -91,20 +52,8 @@ const useLanguageEditorContext = (defaultLanguages: LanguageTag[]) => {
|
|||
setPreAddedLanguage,
|
||||
setIsDirty,
|
||||
setConfirmationState,
|
||||
startAddingLanguage,
|
||||
stopAddingLanguage,
|
||||
}),
|
||||
[
|
||||
confirmationState,
|
||||
isAddingLanguage,
|
||||
isDirty,
|
||||
languages,
|
||||
preAddedLanguage,
|
||||
preSelectedLanguage,
|
||||
selectedLanguage,
|
||||
startAddingLanguage,
|
||||
stopAddingLanguage,
|
||||
]
|
||||
[confirmationState, isDirty, preAddedLanguage, preSelectedLanguage, selectedLanguage]
|
||||
);
|
||||
|
||||
return {
|
|
@ -0,0 +1,35 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
|
||||
import LanguageEditor from './LanguageEditor';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ManageLanguageButton = ({ className }: Props) => {
|
||||
const [isLanguageEditorOpen, setIsLanguageEditorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title="sign_in_exp.others.languages.manage_language"
|
||||
className={className}
|
||||
onClick={() => {
|
||||
setIsLanguageEditorOpen(true);
|
||||
}}
|
||||
/>
|
||||
<LanguageEditor
|
||||
isOpen={isLanguageEditorOpen}
|
||||
onClose={() => {
|
||||
setIsLanguageEditorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageLanguageButton;
|
|
@ -1,16 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.sectionTitle {
|
||||
@include _.subhead-cap;
|
||||
background-color: var(--color-layer-light);
|
||||
}
|
||||
|
||||
.sectionDataKey {
|
||||
padding: _.unit(4) _.unit(5);
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sectionBuiltInText {
|
||||
padding: _.unit(2) 0;
|
||||
}
|
|
@ -11,7 +11,7 @@
|
|||
}
|
||||
|
||||
.primaryTag {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.method {
|
||||
|
@ -26,15 +26,16 @@
|
|||
display: flex;
|
||||
align-items: baseline;
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
||||
.manageLanguage {
|
||||
margin-top: _.unit(2);
|
||||
.manageLanguageButton {
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
||||
.defaultLanguageDescription {
|
||||
padding-top: _.unit(2);
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
}
|
||||
|
||||
.eyeIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.empty {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-medium);
|
||||
}
|
||||
|
||||
|
|
|
@ -126,7 +126,8 @@ const UserConnectors = ({ userId, connectors, onDelete }: Props) => {
|
|||
<td>
|
||||
<Button
|
||||
title="user_details.connectors.remove"
|
||||
type="plain"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDeletingConnector(connector);
|
||||
}}
|
||||
|
|
|
@ -40,13 +40,13 @@
|
|||
}
|
||||
|
||||
.username {
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-subhead-2);
|
||||
}
|
||||
|
||||
.text {
|
||||
font: var(--font-subhead-2);
|
||||
color: var(--color-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.verticalBar {
|
||||
|
@ -56,7 +56,7 @@
|
|||
}
|
||||
|
||||
.moreIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,5 +87,5 @@
|
|||
}
|
||||
|
||||
.resetIcon {
|
||||
color: var(--color-icon);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue