0
Fork 0
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:
Darcy Ye 2022-10-14 17:55:58 +08:00
commit 025456c16d
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
213 changed files with 3737 additions and 3372 deletions

View file

@ -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/')

View file

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

View file

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

View file

@ -48,7 +48,8 @@
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"react": "^18.0.0"
"react": "^18.0.0",
"jest": "^29.1.2"
}
}
}

View file

@ -1 +1,2 @@
alteration-scripts/
src/package-json.ts

View file

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

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

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

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

View file

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

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

View file

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

View file

@ -0,0 +1 @@
export type AlterationFile = { path: string; filename: string };

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export const connectorDirectory = 'connectors';

View file

@ -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()

View file

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

View file

@ -4,7 +4,8 @@
"outDir": "lib",
"declaration": true,
"module": "node16",
"target": "es2022"
"target": "es2022",
"types": ["node", "jest"]
},
"include": [
"src",

View file

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

View 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

View file

@ -16,7 +16,7 @@
.icon {
width: 20px;
height: 20px;
color: var(--color-icon);
color: var(--color-text-secondary);
}
.content {

View file

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

View file

@ -26,7 +26,7 @@
.description {
font: var(--font-body-medium);
color: var(--color-caption);
color: var(--color-text-secondary);
}
}

View file

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

View file

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

View file

@ -6,7 +6,7 @@
align-items: center;
.title {
color: var(--color-caption);
color: var(--color-text-secondary);
font: var(--font-body-medium);
}

View file

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

View file

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

View file

@ -10,7 +10,7 @@
.subtitle {
margin-top: _.unit(1);
color: var(--color-caption);
color: var(--color-text-secondary);
}
&.large {

View file

@ -33,6 +33,11 @@
.copyIcon {
margin-left: _.unit(3);
svg {
width: 16px;
height: 16px;
}
}
}
}

View file

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

View file

@ -15,7 +15,7 @@
align-items: center;
> svg {
color: var(--color-caption);
color: var(--color-text-secondary);
}
&:disabled {

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@
margin-bottom: _.unit(6);
.closeIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
}

View file

@ -17,7 +17,7 @@
}
.minusIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
.addAnother {

View file

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

View file

@ -13,6 +13,6 @@
}
.searchIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
}

View file

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

View file

@ -42,7 +42,7 @@
.icon {
display: flex;
margin-left: _.unit(3);
color: var(--color-icon);
color: var(--color-text-secondary);
}
.clear {

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
.userId {
font: var(--body-small);
color: var(--color-caption);
color: var(--color-text-secondary);
margin-left: _.unit(1);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -51,7 +51,7 @@
align-items: center;
.moreIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
> *:not(:first-child) {

View file

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

View file

@ -14,7 +14,7 @@
}
.closeIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
.githubIcon {

View file

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

View file

@ -19,5 +19,5 @@
.description {
font: var(--font-body-medium);
color: var(--color-caption);
color: var(--color-text-secondary);
}

View file

@ -12,7 +12,7 @@
}
.label {
color: var(--color-icon);
color: var(--color-text-secondary);
font: var(--font-body-medium);
text-align: center;
}

View file

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

View file

@ -22,7 +22,7 @@
.description {
font: var(--font-body-medium);
color: var(--color-caption);
color: var(--color-text-secondary);
}
.successfulTooltip {

View file

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@
}
.closeIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
}

View file

@ -38,7 +38,7 @@
margin-left: _.unit(1);
width: 16px;
height: 16px;
color: var(--color-caption);
color: var(--color-text-secondary);
> svg {
display: block;

View file

@ -15,6 +15,6 @@
.label {
font: var(--font-body-small);
color: var(--color-icon);
color: var(--color-text-secondary);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,6 @@
height: 16px;
object-fit: cover;
margin-left: _.unit(1);
color: var(--color-icon);
color: var(--color-text-secondary);
}
}

View file

@ -25,7 +25,7 @@
}
.closeIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@
}
.eyeIcon {
color: var(--color-icon);
color: var(--color-text-secondary);
}
}
}

View file

@ -1,7 +1,7 @@
@use '@/scss/underscore' as _;
.empty {
color: var(--color-caption);
color: var(--color-text-secondary);
font: var(--font-body-medium);
}

View file

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

View file

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