0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge branch 'master' into gao-fix-sie-forget-pwd-enabled

This commit is contained in:
Gao Sun 2022-12-13 16:21:08 +08:00
commit 500b985d31
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
743 changed files with 17379 additions and 7457 deletions

1
.github/CODEOWNERS vendored
View file

@ -1,2 +1,3 @@
/packages/schemas/tables @simeng-li @wangsijie
/packages/core/src/routes/session @simeng-li @wangsijie
/.changeset @gao-sun

View file

@ -84,7 +84,7 @@ jobs:
- name: Add mock connectors
working-directory: tests
run: |
npm run cli connector add @logto/connector-mock-sms @logto/connector-mock-email @logto/connector-mock-social -- -p ../logto
npm run cli connector add @logto/connector-mock-sms @logto/connector-mock-email @logto/connector-mock-standard-email @logto/connector-mock-social -- -p ../logto
- name: Run Logto
working-directory: logto/

View file

@ -20,20 +20,22 @@ jobs:
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2
with:
node-version: 18
- name: Build
run: pnpm ci:build
main-lint:
# avoid out of memory issue since macOS has bigger memory
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
runs-on: ubuntu-latest-4-cores
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2
with:
node-version: 18
- name: Prepack
run: pnpm prepack
@ -59,8 +61,8 @@ jobs:
with:
node-version: ${{ matrix.node_version }}
- name: Prepack
run: pnpm prepack
- name: Build for test
run: pnpm -r build:test
- name: Test
run: pnpm ci:test

View file

@ -90,6 +90,7 @@ jobs:
with:
# Set Git operations with the bot PAT since we have tag protection rule
token: ${{ secrets.BOT_PAT }}
fetch-depth: 0
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2

1
.npmrc
View file

@ -3,3 +3,4 @@ public-hoist-pattern[]=@parcel/*
public-hoist-pattern[]=postcss
public-hoist-pattern[]=process
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=buffer

View file

@ -68,7 +68,7 @@ npm init @logto
## Language support
```ts
const languages = ['English', 'Français', 'Português', '简体中文', 'Türkçe', '한국어'];
const languages = ['Deutsch', 'English', 'Français', 'Português', '简体中文', 'Türkçe', '한국어'];
```
## Bug report, feature request, feedback

View file

@ -0,0 +1,44 @@
# This compose file is for demonstration only, do not use in prod.
version: "3.9"
x-uffizzi:
ingress:
service: app
port: 3001
continuous_previews:
deploy_preview_when_pull_request_is_opened: true
delete_preview_when_pull_request_is_closed: true
share_to_github: true
services:
app:
depends_on:
- "postgres"
build:
context: ./
dockerfile: ./Dockerfile
ports:
- 3001:3001
environment:
TRUST_PROXY_HEADER: 1
DB_URL: postgres://postgres:p0stgr3s@localhost:5432/logto
deploy:
resources:
limits:
memory: 2000M
entrypoint: /bin/sh
command:
- "-c"
- "npm run cli db seed -- --swe && ENDPOINT=$$UFFIZZI_URL npm start"
postgres:
image: postgres:14-alpine
user: postgres
environment:
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "p0stgr3s"
deploy:
resources:
limits:
memory: 500M

View file

@ -24,7 +24,7 @@
"@commitlint/config-conventional": "^17.0.0",
"@commitlint/types": "^17.0.0",
"husky": "^8.0.0",
"typescript": "^4.7.4"
"typescript": "^4.9.4"
},
"workspaces": {
"packages": [

View file

@ -1,2 +0,0 @@
#!/usr/bin/env node
require('../lib/index.js');

2
packages/cli/bin/logto.js Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../lib/index.js';

View file

@ -0,0 +1,11 @@
/** @type {import('jest').Config} */
const config = {
coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'],
coverageReporters: ['text-summary', 'lcov'],
roots: ['./lib'],
moduleNameMapper: {
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',
},
};
export default config;

View file

@ -1,7 +0,0 @@
import { merge, Config } from '@silverhand/jest-config';
const config: Config.InitialOptions = merge({
roots: ['./src'],
});
export default config;

View file

@ -5,12 +5,13 @@
"author": "Silverhand Inc. <contact@silverhand.io>",
"homepage": "https://github.com/logto-io/logto#readme",
"license": "MPL-2.0",
"type": "module",
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"bin": {
"logto": "bin/logto"
"logto": "bin/logto.js"
},
"files": [
"bin",
@ -24,13 +25,15 @@
"precommit": "lint-staged",
"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",
"build:test": "rimraf lib/ && pnpm prepare:package-json && tsc -p tsconfig.test.json --sourcemap",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
"start": "node .",
"start:dev": "ts-node --files src/index.ts",
"start:dev": "pnpm build && node .",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"test": "jest",
"test:ci": "jest",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build:test && pnpm test:only",
"test:ci": "pnpm run test:only",
"prepack": "pnpm build"
},
"engines": {
@ -40,21 +43,21 @@
"url": "https://github.com/logto-io/logto/issues"
},
"dependencies": {
"@logto/schemas": "workspace:^",
"@logto/shared": "workspace:^",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "^1.3.0",
"chalk": "^4.1.2",
"chalk": "^5.0.0",
"decamelize": "^5.0.0",
"dotenv": "^16.0.0",
"fs-extra": "^10.1.0",
"got": "^11.8.5",
"hpagent": "^1.0.0",
"got": "^12.5.3",
"hpagent": "^1.2.0",
"inquirer": "^8.2.2",
"nanoid": "^3.3.4",
"ora": "^5.0.0",
"ora": "^6.1.2",
"p-retry": "^4.6.1",
"roarr": "^7.11.0",
"semver": "^7.3.7",
"semver": "^7.3.8",
"slonik": "^30.0.0",
"slonik-interceptor-preset": "^1.2.10",
"slonik-sql-tag-raw": "^1.1.4",
@ -64,22 +67,22 @@
},
"devDependencies": {
"@silverhand/eslint-config": "1.3.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": "^29.1.2",
"@types/node": "^16.0.0",
"@types/semver": "^7.3.12",
"@types/sinon": "^10.0.13",
"@types/tar": "^6.1.2",
"@types/yargs": "^17.0.13",
"eslint": "^8.21.0",
"jest": "^29.1.2",
"jest": "^29.3.1",
"lint-staged": "^13.0.0",
"prettier": "^2.7.1",
"rimraf": "^3.0.2",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
"sinon": "^15.0.0",
"typescript": "^4.9.4"
},
"eslintConfig": {
"extends": "@silverhand",

View file

@ -1,7 +1,7 @@
import type { CommandModule } from 'yargs';
import { log } from '../../utilities';
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils';
import { log } from '../../utilities.js';
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils.js';
const add: CommandModule<
{ path?: string },

View file

@ -1,9 +1,9 @@
import { noop } from '@silverhand/essentials';
import type { CommandModule } from 'yargs';
import add from './add';
import list from './list';
import remove from './remove';
import add from './add.js';
import list from './list.js';
import remove from './remove.js';
const connector: CommandModule = {
command: ['connector', 'c', 'connectors'],

View file

@ -1,7 +1,7 @@
import chalk from 'chalk';
import type { CommandModule } from 'yargs';
import { getConnectorPackagesFrom, isOfficialConnector } from './utils';
import { getConnectorPackagesFrom, isOfficialConnector } from './utils.js';
const logConnectorNames = (type: string, names: string[]) => {
if (names.length === 0) {

View file

@ -2,8 +2,8 @@ import chalk from 'chalk';
import fsExtra from 'fs-extra';
import type { CommandModule } from 'yargs';
import { log } from '../../utilities';
import { getConnectorPackagesFrom } from './utils';
import { log } from '../../utilities.js';
import { getConnectorPackagesFrom } from './utils.js';
const remove: CommandModule<{ path?: string }, { path?: string; packages?: string[] }> = {
command: ['remove [packages...]', 'rm', 'delete'],

View file

@ -6,15 +6,15 @@ import { promisify } from 'util';
import { assert, conditionalString } from '@silverhand/essentials';
import chalk from 'chalk';
import { ensureDir, remove } from 'fs-extra';
import fsExtra from 'fs-extra';
import inquirer from 'inquirer';
import pRetry from 'p-retry';
import tar from 'tar';
import { z } from 'zod';
import { connectorDirectory } from '../../constants';
import { getConnectorPackagesFromDirectory, isTty, log, oraPromise } from '../../utilities';
import { defaultPath } from '../install/utils';
import { connectorDirectory } from '../../constants.js';
import { getConnectorPackagesFromDirectory, isTty, log, oraPromise } from '../../utilities.js';
import { defaultPath } from '../install/utils.js';
const coreDirectory = 'packages/core';
const execPromise = promisify(exec);
@ -136,8 +136,8 @@ export const addConnectors = async (instancePath: string, packageNames: string[]
const tarPath = path.join(cwd, escapedFilename);
const packageDirectory = path.join(cwd, name.replace(/\//g, '-'));
await remove(packageDirectory);
await ensureDir(packageDirectory);
await fsExtra.remove(packageDirectory);
await fsExtra.ensureDir(packageDirectory);
await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 });
await unlink(tarPath);

View file

@ -1,42 +1,45 @@
import { mockEsmWithActual } from '@logto/shared/esm';
import Sinon from 'sinon';
import { createMockPool } from 'slonik';
import * as functions from '.';
import * as queries from '../../../queries/logto-config';
import type { QueryType } from '../../../test-utilities';
import { chooseAlterationsByVersion } from './version';
import { chooseAlterationsByVersion } from './version.js';
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
const { jest } = import.meta;
const pool = createMockPool({
query: async (sql, values) => {
return mockQuery(sql, values);
},
query: jest.fn(),
});
const files = Object.freeze([
{ filename: '1.0.0-1663923770-a.js', path: '/alterations-js/1.0.0-1663923770-a.js' },
{ filename: '1.0.0-1663923771-b.js', path: '/alterations-js/1.0.0-1663923771-b.js' },
{ filename: '1.0.0-1663923772-c.js', path: '/alterations-js/1.0.0-1663923772-c.js' },
]);
await mockEsmWithActual('./utils.js', () => ({
getAlterationFiles: async () => files,
}));
const { getCurrentDatabaseAlterationTimestamp } = await mockEsmWithActual(
'../../../queries/logto-config.js',
() => ({
getCurrentDatabaseAlterationTimestamp: jest.fn(),
})
);
const { getUndeployedAlterations } = await import('./index.js');
describe('getUndeployedAlterations()', () => {
const files = Object.freeze([
{ filename: '1.0.0-1663923770-a.js', path: '/alterations-js/1.0.0-1663923770-a.js' },
{ filename: '1.0.0-1663923771-b.js', path: '/alterations-js/1.0.0-1663923771-b.js' },
{ filename: '1.0.0-1663923772-c.js', path: '/alterations-js/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);
getCurrentDatabaseAlterationTimestamp.mockResolvedValue(0);
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files);
await expect(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);
getCurrentDatabaseAlterationTimestamp.mockResolvedValue(1_663_923_770);
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
await expect(getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
});
});
@ -58,12 +61,19 @@ describe('chooseAlterationsByVersion()', () => {
'next1-1663923781-c.js',
].map((filename) => ({ filename, path: '/alterations/' + filename }))
);
const stub = Sinon.stub(global, 'process').value({ stdin: { isTTY: false } });
afterAll(() => {
stub.restore();
});
it('chooses nothing when input version is invalid', async () => {
await expect(chooseAlterationsByVersion(files, 'next1')).rejects.toThrow(
'Invalid Version: next1'
new TypeError('Invalid Version: next1')
);
await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow(
new TypeError('Invalid Version: ok')
);
await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow('Invalid Version: ok');
});
it('chooses correct alteration files', async () => {

View file

@ -1,33 +1,18 @@
import path from 'path';
import type { AlterationScript } from '@logto/schemas/lib/types/alteration';
import { findPackage } from '@logto/shared';
import type { AlterationScript } from '@logto/schemas/lib/types/alteration.js';
import { conditionalString } from '@silverhand/essentials';
import chalk from 'chalk';
import { copy, existsSync, remove, readdir } from 'fs-extra';
import type { DatabasePool } from 'slonik';
import type { CommandModule } from 'yargs';
import { createPoolFromConfig } from '../../../database';
import { createPoolFromConfig } from '../../../database.js';
import {
getCurrentDatabaseAlterationTimestamp,
updateDatabaseTimestamp,
} from '../../../queries/logto-config';
import { getPathInModule, log } from '../../../utilities';
import type { AlterationFile } from './type';
import { chooseAlterationsByVersion } from './version';
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
const getTimestampFromFilename = (filename: string) => {
const match = alterationFilenameRegex.exec(filename);
if (!match?.[1]) {
throw new Error(`Can not get timestamp: ${filename}`);
}
return Number(match[1]);
};
} from '../../../queries/logto-config.js';
import { log } from '../../../utilities.js';
import type { AlterationFile } from './type.js';
import { getAlterationFiles, getTimestampFromFilename } from './utils.js';
import { chooseAlterationsByVersion } from './version.js';
const importAlterationScript = async (filePath: string): Promise<AlterationScript> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -37,44 +22,6 @@ const importAlterationScript = async (filePath: string): Promise<AlterationScrip
return module.default as AlterationScript;
};
export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
const alterationDirectory = getPathInModule('@logto/schemas', 'alterations-js');
/**
* We copy all alteration scripts to the CLI package root directory,
* since they need a proper context that includes required dependencies (such as slonik) in `node_modules/`.
* While the original `@logto/schemas` may remove them in production.
*/
const packageDirectory = await findPackage(
// Until we migrate to ESM
// eslint-disable-next-line unicorn/prefer-module
__dirname
);
const localAlterationDirectory = path.resolve(
// Until we migrate to ESM
// eslint-disable-next-line unicorn/prefer-module
packageDirectory ?? __dirname,
'alteration-scripts'
);
if (!existsSync(alterationDirectory)) {
return [];
}
// We need to copy alteration files to execute in the CLI context to make `slonik` available
await remove(localAlterationDirectory);
await copy(alterationDirectory, localAlterationDirectory);
const directory = await readdir(localAlterationDirectory);
const files = directory.filter((file) => alterationFilenameRegex.test(file));
return files
.slice()
.sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2))
.map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename }));
};
export const getLatestAlterationTimestamp = async () => {
const files = await getAlterationFiles();
const lastFile = files[files.length - 1];

View file

@ -0,0 +1,4 @@
// Have to define this in a separate file since Jest sticks with CJS
// We need to mock this before running tests
// https://github.com/facebook/jest/issues/12952
export const metaUrl = import.meta.url;

View file

@ -0,0 +1,55 @@
import { fileURLToPath } from 'node:url';
import path from 'path';
import { findPackage } from '@logto/shared';
import fsExtra from 'fs-extra';
import { getPathInModule } from '../../../utilities.js';
import { metaUrl } from './meta-url.js';
import type { AlterationFile } from './type.js';
const currentDirname = path.dirname(fileURLToPath(metaUrl));
const { copy, existsSync, remove, readdir } = fsExtra;
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
export const getTimestampFromFilename = (filename: string) => {
const match = alterationFilenameRegex.exec(filename);
if (!match?.[1]) {
throw new Error(`Can not get timestamp: ${filename}`);
}
return Number(match[1]);
};
export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
const alterationDirectory = getPathInModule('@logto/schemas', 'alterations-js');
/**
* We copy all alteration scripts to the CLI package root directory,
* since they need a proper context that includes required dependencies (such as slonik) in `node_modules/`.
* While the original `@logto/schemas` may remove them in production.
*/
const packageDirectory = await findPackage(currentDirname);
const localAlterationDirectory = path.resolve(
packageDirectory ?? currentDirname,
'alteration-scripts'
);
if (!existsSync(alterationDirectory)) {
return [];
}
// We need to copy alteration files to execute in the CLI context to make `slonik` available
await remove(localAlterationDirectory);
await copy(alterationDirectory, localAlterationDirectory);
const directory = await readdir(localAlterationDirectory);
const files = directory.filter((file) => alterationFilenameRegex.test(file));
return files
.slice()
.sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2))
.map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename }));
};

View file

@ -3,8 +3,8 @@ import chalk from 'chalk';
import inquirer from 'inquirer';
import { SemVer, compare, eq, gt } from 'semver';
import { findLastIndex, isTty, log } from '../../../utilities';
import type { AlterationFile } from './type';
import { findLastIndex, isTty, log } from '../../../utilities.js';
import type { AlterationFile } from './type.js';
const getVersionFromFilename = (filename: string) => {
try {
@ -42,6 +42,7 @@ export const chooseAlterationsByVersion = async (
.filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
.slice()
.sort((i, j) => compare(j, i));
const initialSemVersion = conditional(
initialVersion && initialVersion !== latestTag && new SemVer(initialVersion)
);

View file

@ -4,10 +4,10 @@ import { deduplicate, noop } from '@silverhand/essentials';
import chalk from 'chalk';
import type { CommandModule } from 'yargs';
import { createPoolFromConfig } from '../../database';
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config';
import { log } from '../../utilities';
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities';
import { createPoolFromConfig } from '../../database.js';
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js';
import { log } from '../../utilities.js';
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities.js';
const validKeysDisplay = chalk.green(logtoConfigKeys.join(', '));

View file

@ -1,9 +1,9 @@
import { noop } from '@silverhand/essentials';
import type { CommandModule } from 'yargs';
import alteration from './alteration';
import config from './config';
import seed from './seed';
import alteration from './alteration/index.js';
import config from './config.js';
import seed from './seed/index.js';
const database: CommandModule = {
command: ['database', 'db'],

View file

@ -10,16 +10,16 @@ import { raw } from 'slonik-sql-tag-raw';
import type { CommandModule } from 'yargs';
import { z } from 'zod';
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database';
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database.js';
import {
getRowsByKeys,
doesConfigsTableExist,
updateDatabaseTimestamp,
updateValueByKey,
} from '../../../queries/logto-config';
import { getPathInModule, log, oraPromise } from '../../../utilities';
import { getLatestAlterationTimestamp } from '../alteration';
import { oidcConfigReaders } from './oidc-config';
} from '../../../queries/logto-config.js';
import { getPathInModule, log, oraPromise } from '../../../utilities.js';
import { getLatestAlterationTimestamp } from '../alteration/index.js';
import { oidcConfigReaders } from './oidc-config.js';
const createTables = async (connection: DatabaseTransactionConnection) => {
const tableDirectory = getPathInModule('@logto/schemas', 'tables');

View file

@ -4,7 +4,7 @@ import type { LogtoOidcConfigType } from '@logto/schemas';
import { LogtoOidcConfigKey } from '@logto/schemas';
import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities';
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js';
const isBase64FormatPrivateKey = (key: string) => !key.includes('-');

View file

@ -1,9 +1,9 @@
import chalk from 'chalk';
import type { CommandModule } from 'yargs';
import { getDatabaseUrlFromConfig } from '../../database';
import { log } from '../../utilities';
import { addOfficialConnectors } from '../connector/utils';
import { getDatabaseUrlFromConfig } from '../../database.js';
import { log } from '../../utilities.js';
import { addOfficialConnectors } from '../connector/utils.js';
import {
validateNodeVersion,
inquireInstancePath,
@ -15,7 +15,7 @@ import {
decompress,
inquireOfficialConnectors,
isUrl,
} from './utils';
} from './utils.js';
export type InstallArgs = {
path?: string;

View file

@ -6,12 +6,12 @@ import path from 'path';
import { assert } from '@silverhand/essentials';
import chalk from 'chalk';
import { remove, writeFile } from 'fs-extra';
import fsExtra from 'fs-extra';
import inquirer from 'inquirer';
import * as semver from 'semver';
import tar from 'tar';
import { createPoolAndDatabaseIfNeeded } from '../../database';
import { createPoolAndDatabaseIfNeeded } from '../../database.js';
import {
cliConfig,
ConfigKey,
@ -20,8 +20,8 @@ import {
log,
oraPromise,
safeExecSync,
} from '../../utilities';
import { seedByPool } from '../database/seed';
} from '../../utilities.js';
import { seedByPool } from '../database/seed/index.js';
export const defaultPath = path.join(os.homedir(), 'logto');
const pgRequired = new semver.SemVer('14.0.0');
@ -140,7 +140,7 @@ export const seedDatabase = async (instancePath: string) => {
} catch (error: unknown) {
console.error(error);
await oraPromise(remove(instancePath), {
await oraPromise(fsExtra.remove(instancePath), {
text: 'Clean up',
prefixText: chalk.blue('[info]'),
});
@ -156,7 +156,7 @@ export const seedDatabase = async (instancePath: string) => {
export const createEnv = async (instancePath: string, databaseUrl: string) => {
const dotEnvPath = path.resolve(instancePath, '.env');
await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, {
await fsExtra.writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, {
encoding: 'utf8',
});
log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`);

View file

@ -6,7 +6,7 @@ import { createPool, parseDsn, sql, stringifyDsn } from 'slonik';
import { createInterceptors } from 'slonik-interceptor-preset';
import { z } from 'zod';
import { ConfigKey, getCliConfigWithPrompt, log } from './utilities';
import { ConfigKey, getCliConfigWithPrompt, log } from './utilities.js';
export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto';

View file

@ -3,11 +3,11 @@ import dotenv from 'dotenv';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import connector from './commands/connector';
import database from './commands/database';
import install from './commands/install';
import { packageJson } from './package-json';
import { cliConfig, ConfigKey } from './utilities';
import connector from './commands/connector/index.js';
import database from './commands/database/index.js';
import install from './commands/install/index.js';
import { packageJson } from './package-json.js';
import { cliConfig, ConfigKey } from './utilities.js';
void yargs(hideBin(process.argv))
.version(false)

View file

@ -0,0 +1,4 @@
// Have to define this in a separate file since Jest sticks with CJS
// We need to mock this before running tests
// https://github.com/facebook/jest/issues/12952
export const metaUrl = import.meta.url;

View file

@ -2,10 +2,11 @@ import { AlterationStateKey, LogtoConfigs } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import type { QueryType } from '../test-utilities';
import { expectSqlAssert } from '../test-utilities';
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config';
import type { QueryType } from '../test-utilities.js';
import { expectSqlAssert } from '../test-utilities.js';
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
const pool = createMockPool({

View file

@ -1,7 +1,7 @@
// Copied from core
import type { QueryResult, QueryResultRow } from 'slonik';
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.d';
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.js';
export type QueryType = (
sql: string,

View file

@ -1,18 +1,22 @@
import { execSync } from 'child_process';
import { createWriteStream, existsSync } from 'fs';
import { readdir, readFile } from 'fs/promises';
import { createRequire } from 'module';
import path from 'path';
import type { Optional } from '@silverhand/essentials';
import { conditionalString } from '@silverhand/essentials';
import chalk from 'chalk';
import type { Progress } from 'got';
import got from 'got';
import { got } from 'got';
import { HttpsProxyAgent } from 'hpagent';
import inquirer from 'inquirer';
import type { Options } from 'ora';
import ora from 'ora';
import { z } from 'zod';
import { metaUrl } from './meta-url.js';
export const safeExecSync = (command: string) => {
try {
return execSync(command, { encoding: 'utf8', stdio: 'pipe' });
@ -83,15 +87,13 @@ export const downloadFile = async (url: string, destination: string) => {
export const getPathInModule = (moduleName: string, relativePath = '/') =>
// https://stackoverflow.com/a/49455609/12514940
path.join(
// Until we migrate to ESM
// eslint-disable-next-line unicorn/prefer-module
path.dirname(require.resolve(`${moduleName}/package.json`)),
path.dirname(createRequire(metaUrl).resolve(`${moduleName}/package.json`)),
relativePath
);
export const oraPromise = async <T>(
promise: PromiseLike<T>,
options?: ora.Options,
options?: Options,
exitOnError = false
) => {
const spinner = ora(options).start();

View file

@ -3,13 +3,13 @@
"compilerOptions": {
"outDir": "lib",
"declaration": true,
"module": "node16",
"moduleResolution": "nodenext",
"module": "esnext",
"target": "es2022",
"types": ["node", "jest"]
},
"include": [
"src",
"jest.config.ts"
"src"
],
"exclude": ["**/alteration-scripts"]
}

View file

@ -1,6 +1,8 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false,
"allowJs": true
}
},
"include": ["src"]
}

View file

@ -0,0 +1,13 @@
{
"plugins": [
{
"name": "preset-default",
"params": {
"overrides": {
"cleanupIDs": false,
"removeViewBox": false
}
}
}
]
}

View file

@ -5,6 +5,7 @@
"author": "Silverhand Inc. <contact@silverhand.io>",
"homepage": "https://github.com/logto-io/logto#readme",
"license": "MPL-2.0",
"type": "module",
"private": true,
"scripts": {
"precommit": "lint-staged",
@ -18,17 +19,17 @@
},
"devDependencies": {
"@fontsource/roboto-mono": "^4.5.7",
"@logto/core-kit": "1.0.0-beta.20",
"@logto/language-kit": "1.0.0-beta.20",
"@logto/phrases": "workspace:^",
"@logto/phrases-ui": "workspace:^",
"@logto/react": "1.0.0-beta.13",
"@logto/schemas": "workspace:^",
"@logto/core-kit": "1.0.0-beta.28",
"@logto/language-kit": "1.0.0-beta.28",
"@logto/phrases": "workspace:*",
"@logto/phrases-ui": "workspace:*",
"@logto/react": "1.0.0-beta.14",
"@logto/schemas": "workspace:*",
"@mdx-js/react": "^1.6.22",
"@parcel/core": "2.7.0",
"@parcel/transformer-mdx": "2.7.0",
"@parcel/transformer-sass": "2.7.0",
"@parcel/transformer-svg-react": "2.7.0",
"@parcel/core": "2.8.0",
"@parcel/transformer-mdx": "2.8.0",
"@parcel/transformer-sass": "2.8.0",
"@parcel/transformer-svg-react": "2.8.0",
"@silverhand/eslint-config": "1.3.0",
"@silverhand/eslint-config-react": "1.3.0",
"@silverhand/essentials": "^1.3.0",
@ -48,21 +49,21 @@
"clean-deep": "^3.4.0",
"cross-env": "^7.0.3",
"csstype": "^3.0.11",
"date-fns": "^2.29.3",
"dayjs": "^1.10.5",
"deep-object-diff": "^1.1.7",
"date-fns": "^2.29.3",
"deepmerge": "^4.2.2",
"dnd-core": "^16.0.0",
"eslint": "^8.21.0",
"history": "^5.3.0",
"i18next": "^21.8.16",
"i18next-browser-languagedetector": "^6.1.4",
"ky": "^0.31.0",
"ky": "^0.32.0",
"lint-staged": "^13.0.0",
"lodash.get": "^4.4.2",
"lodash.kebabcase": "^4.1.1",
"nanoid": "^3.1.23",
"parcel": "2.7.0",
"nanoid": "^3.3.4",
"parcel": "2.8.0",
"postcss": "^8.4.6",
"postcss-modules": "^4.3.0",
"prettier": "^2.7.1",
@ -86,7 +87,7 @@
"snake-case": "^3.0.4",
"stylelint": "^14.9.1",
"swr": "^1.3.0",
"typescript": "^4.7.4",
"typescript": "^4.9.4",
"zod": "^3.19.1"
},
"engines": {

View file

@ -55,7 +55,6 @@ const Main = () => {
<Route path=":id">
<Route index element={<Navigate replace to="settings" />} />
<Route path="settings" element={<ApplicationDetails />} />
<Route path="advanced-settings" element={<ApplicationDetails />} />
</Route>
</Route>
<Route path="api-resources">

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.47345 9.88663L11.3001 7.05996C11.3626 6.99799 11.4122 6.92425 11.446 6.84301C11.4799 6.76177 11.4973 6.67464 11.4973 6.58663C11.4973 6.49862 11.4799 6.41148 11.446 6.33024C11.4122 6.249 11.3626 6.17527 11.3001 6.1133C11.1752 5.98913 11.0062 5.91943 10.8301 5.91943C10.654 5.91943 10.485 5.98913 10.3601 6.1133L8.00012 8.47329L5.64012 6.11329C5.51521 5.98913 5.34624 5.91943 5.17012 5.91943C4.99399 5.91943 4.82502 5.98913 4.70012 6.11329C4.63833 6.17559 4.58944 6.24947 4.55627 6.33069C4.52309 6.41192 4.50628 6.49889 4.50678 6.58663C4.50628 6.67437 4.52309 6.76134 4.55627 6.84257C4.58944 6.92379 4.63833 6.99767 4.70012 7.05996L7.52678 9.88663C7.58876 9.94911 7.66249 9.99871 7.74373 10.0326C7.82497 10.0664 7.91211 10.0838 8.00012 10.0838C8.08812 10.0838 8.17526 10.0664 8.2565 10.0326C8.33774 9.99871 8.41147 9.94911 8.47345 9.88663Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 976 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.52655 6.11337L4.69988 8.94004C4.6374 9.00201 4.5878 9.07575 4.55396 9.15699C4.52011 9.23823 4.50269 9.32536 4.50269 9.41337C4.50269 9.50138 4.52011 9.58852 4.55396 9.66976C4.5878 9.751 4.6374 9.82473 4.69988 9.8867C4.82479 10.0109 4.99376 10.0806 5.16988 10.0806C5.34601 10.0806 5.51498 10.0109 5.63988 9.8867L7.99988 7.5267L10.3599 9.8867C10.4848 10.0109 10.6538 10.0806 10.8299 10.0806C11.006 10.0806 11.175 10.0109 11.2999 9.8867C11.3617 9.82441 11.4106 9.75053 11.4437 9.66931C11.4769 9.58808 11.4937 9.50111 11.4932 9.41337C11.4937 9.32563 11.4769 9.23866 11.4437 9.15743C11.4106 9.07621 11.3617 9.00233 11.2999 8.94004L8.47322 6.11337C8.41124 6.05089 8.33751 6.00129 8.25627 5.96744C8.17503 5.9336 8.08789 5.91617 7.99988 5.91617C7.91188 5.91617 7.82474 5.9336 7.7435 5.96744C7.66226 6.00129 7.58853 6.05089 7.52655 6.11337Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 969 B

View file

@ -1,5 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.00017 1.33325C6.68162 1.33325 5.39269 1.72425 4.29636 2.45679C3.20004 3.18933 2.34555 4.23052 1.84097 5.4487C1.33638 6.66687 1.20436 8.00731 1.4616 9.30052C1.71883 10.5937 2.35377 11.7816 3.28612 12.714C4.21847 13.6463 5.40636 14.2813 6.69956 14.5385C7.99277 14.7957 9.33321 14.6637 10.5514 14.1591C11.7696 13.6545 12.8108 12.8 13.5433 11.7037C14.2758 10.6074 14.6668 9.31846 14.6668 7.99992C14.6668 7.12444 14.4944 6.25753 14.1594 5.4487C13.8243 4.63986 13.3333 3.90493 12.7142 3.28587C12.0952 2.66682 11.3602 2.17575 10.5514 1.84072C9.74255 1.50569 8.87564 1.33325 8.00017 1.33325ZM8.00017 13.3333C6.94533 13.3333 5.91419 13.0205 5.03712 12.4344C4.16006 11.8484 3.47648 11.0154 3.07281 10.0409C2.66914 9.06636 2.56352 7.994 2.76931 6.95944C2.9751 5.92487 3.48305 4.97456 4.22893 4.22868C4.97481 3.4828 5.92512 2.97485 6.95968 2.76906C7.99425 2.56328 9.0666 2.66889 10.0411 3.07256C11.0157 3.47623 11.8486 4.15982 12.4347 5.03688C13.0207 5.91394 13.3335 6.94509 13.3335 7.99992C13.3335 9.41441 12.7716 10.771 11.7714 11.7712C10.7712 12.7713 9.41465 13.3333 8.00017 13.3333ZM10.6668 7.33325H8.66683V5.33325C8.66683 5.15644 8.59659 4.98687 8.47157 4.86185C8.34655 4.73682 8.17698 4.66659 8.00017 4.66659C7.82335 4.66659 7.65379 4.73682 7.52876 4.86185C7.40374 4.98687 7.3335 5.15644 7.3335 5.33325V7.33325H5.3335C5.15669 7.33325 4.98712 7.40349 4.86209 7.52851C4.73707 7.65354 4.66683 7.82311 4.66683 7.99992C4.66683 8.17673 4.73707 8.3463 4.86209 8.47132C4.98712 8.59635 5.15669 8.66659 5.3335 8.66659H7.3335V10.6666C7.3335 10.8434 7.40374 11.013 7.52876 11.138C7.65379 11.263 7.82335 11.3333 8.00017 11.3333C8.17698 11.3333 8.34655 11.263 8.47157 11.138C8.59659 11.013 8.66683 10.8434 8.66683 10.6666V8.66659H10.6668C10.8436 8.66659 11.0132 8.59635 11.1382 8.47132C11.2633 8.3463 11.3335 8.17673 11.3335 7.99992C11.3335 7.82311 11.2633 7.65354 11.1382 7.52851C11.0132 7.40349 10.8436 7.33325 10.6668 7.33325Z"
fill="currentColor" />
<path d="M7.99998 1.33325C6.68144 1.33325 5.39251 1.72425 4.29618 2.45679C3.19985 3.18933 2.34537 4.23052 1.84079 5.4487C1.3362 6.66687 1.20418 8.00731 1.46141 9.30052C1.71865 10.5937 2.35359 11.7816 3.28594 12.714C4.21829 13.6463 5.40617 14.2813 6.69938 14.5385C7.99259 14.7957 9.33303 14.6637 10.5512 14.1591C11.7694 13.6545 12.8106 12.8 13.5431 11.7037C14.2757 10.6074 14.6666 9.31846 14.6666 7.99992C14.6666 7.12444 14.4942 6.25753 14.1592 5.4487C13.8241 4.63986 13.3331 3.90493 12.714 3.28587C12.095 2.66682 11.36 2.17575 10.5512 1.84072C9.74237 1.50569 8.87546 1.33325 7.99998 1.33325V1.33325ZM7.99998 13.3333C6.94515 13.3333 5.914 13.0205 5.03694 12.4344C4.15988 11.8484 3.47629 11.0154 3.07263 10.0409C2.66896 9.06636 2.56334 7.994 2.76913 6.95944C2.97492 5.92487 3.48287 4.97456 4.22875 4.22868C4.97463 3.4828 5.92494 2.97485 6.9595 2.76906C7.99407 2.56328 9.06642 2.66889 10.041 3.07256C11.0155 3.47623 11.8485 4.15982 12.4345 5.03688C13.0205 5.91394 13.3333 6.94509 13.3333 7.99992C13.3333 9.41441 12.7714 10.771 11.7712 11.7712C10.771 12.7713 9.41447 13.3333 7.99998 13.3333V13.3333ZM10.6666 7.33325H8.66665V5.33325C8.66665 5.15644 8.59641 4.98687 8.47139 4.86185C8.34636 4.73682 8.17679 4.66659 7.99998 4.66659C7.82317 4.66659 7.6536 4.73682 7.52858 4.86185C7.40355 4.98687 7.33332 5.15644 7.33332 5.33325V7.33325H5.33332C5.1565 7.33325 4.98694 7.40349 4.86191 7.52851C4.73689 7.65354 4.66665 7.82311 4.66665 7.99992C4.66665 8.17673 4.73689 8.3463 4.86191 8.47132C4.98694 8.59635 5.1565 8.66659 5.33332 8.66659H7.33332V10.6666C7.33332 10.8434 7.40355 11.013 7.52858 11.138C7.6536 11.263 7.82317 11.3333 7.99998 11.3333C8.17679 11.3333 8.34636 11.263 8.47139 11.138C8.59641 11.013 8.66665 10.8434 8.66665 10.6666V8.66659H10.6666C10.8435 8.66659 11.013 8.59635 11.1381 8.47132C11.2631 8.3463 11.3333 8.17673 11.3333 7.99992C11.3333 7.82311 11.2631 7.65354 11.1381 7.52851C11.013 7.40349 10.8435 7.33325 10.6666 7.33325Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1,4 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
<path d="M35.0894 14.0219C33.0498 13.086 30.8626 12.3966 28.5759 12.0017C28.5342 11.994 28.4926 12.0131 28.4712 12.0512C28.1899 12.5515 27.8783 13.2041 27.6601 13.7171C25.2005 13.3489 22.7536 13.3489 20.3444 13.7171C20.1262 13.1927 19.8033 12.5515 19.5208 12.0512C19.4993 12.0144 19.4577 11.9953 19.4161 12.0017C17.1305 12.3953 14.9434 13.0848 12.9026 14.0219C12.8849 14.0295 12.8698 14.0422 12.8597 14.0587C8.71119 20.2565 7.57473 26.302 8.13224 32.2725C8.13476 32.3017 8.15116 32.3297 8.17386 32.3474C10.9109 34.3575 13.5623 35.5778 16.1644 36.3866C16.206 36.3993 16.2501 36.3841 16.2766 36.3498C16.8922 35.5092 17.4409 34.6229 17.9113 33.6908C17.9391 33.6363 17.9126 33.5715 17.8558 33.5499C16.9855 33.2198 16.1568 32.8172 15.3596 32.3601C15.2966 32.3233 15.2915 32.2331 15.3495 32.19C15.5173 32.0643 15.6851 31.9335 15.8453 31.8014C15.8743 31.7773 15.9146 31.7722 15.9487 31.7874C21.1857 34.1785 26.8554 34.1785 32.0306 31.7874C32.0647 31.7709 32.1051 31.776 32.1353 31.8001C32.2955 31.9322 32.4633 32.0643 32.6323 32.19C32.6903 32.2331 32.6865 32.3233 32.6235 32.3601C31.8263 32.8261 30.9976 33.2198 30.126 33.5486C30.0693 33.5702 30.044 33.6363 30.0718 33.6908C30.5523 34.6216 31.101 35.5079 31.7052 36.3485C31.7304 36.3841 31.7758 36.3993 31.8175 36.3866C34.4322 35.5778 37.0835 34.3575 39.8206 32.3474C39.8446 32.3297 39.8597 32.303 39.8622 32.2738C40.5294 25.3712 38.7447 19.3753 35.131 14.06C35.1221 14.0422 35.107 14.0295 35.0894 14.0219ZM18.6934 28.6371C17.1167 28.6371 15.8175 27.1896 15.8175 25.4119C15.8175 23.6341 17.0915 22.1866 18.6934 22.1866C20.3078 22.1866 21.5944 23.6469 21.5692 25.4119C21.5692 27.1896 20.2952 28.6371 18.6934 28.6371ZM29.3263 28.6371C27.7497 28.6371 26.4505 27.1896 26.4505 25.4119C26.4505 23.6341 27.7244 22.1866 29.3263 22.1866C30.9408 22.1866 32.2274 23.6469 32.2022 25.4119C32.2022 27.1896 30.9408 28.6371 29.3263 28.6371Z" fill="#A5ABF0"/>
<rect width="48" height="48" rx="12" fill="#191C1D"/>
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
<path d="M35.0894 14.0219C33.0498 13.086 30.8626 12.3966 28.5759 12.0017C28.5342 11.994 28.4926 12.0131 28.4712 12.0512C28.1899 12.5515 27.8783 13.2041 27.6601 13.7171C25.2005 13.3489 22.7536 13.3489 20.3444 13.7171C20.1262 13.1927 19.8033 12.5515 19.5208 12.0512C19.4993 12.0144 19.4577 11.9953 19.4161 12.0017C17.1305 12.3953 14.9434 13.0848 12.9026 14.0219C12.8849 14.0295 12.8698 14.0422 12.8597 14.0587C8.71119 20.2565 7.57473 26.302 8.13224 32.2725C8.13476 32.3017 8.15116 32.3297 8.17386 32.3474C10.9109 34.3575 13.5623 35.5778 16.1644 36.3866C16.206 36.3993 16.2501 36.3841 16.2766 36.3498C16.8922 35.5092 17.4409 34.6229 17.9113 33.6908C17.9391 33.6363 17.9126 33.5715 17.8558 33.5499C16.9855 33.2198 16.1568 32.8172 15.3596 32.3601C15.2966 32.3233 15.2915 32.2331 15.3495 32.19C15.5173 32.0643 15.6851 31.9335 15.8453 31.8014C15.8743 31.7773 15.9146 31.7722 15.9487 31.7874C21.1857 34.1785 26.8554 34.1785 32.0306 31.7874C32.0647 31.7709 32.1051 31.776 32.1353 31.8001C32.2955 31.9322 32.4633 32.0643 32.6323 32.19C32.6903 32.2331 32.6865 32.3233 32.6235 32.3601C31.8263 32.8261 30.9976 33.2198 30.126 33.5486C30.0693 33.5702 30.044 33.6363 30.0718 33.6908C30.5523 34.6216 31.101 35.5079 31.7052 36.3485C31.7304 36.3841 31.7758 36.3993 31.8175 36.3866C34.4322 35.5778 37.0835 34.3575 39.8206 32.3474C39.8446 32.3297 39.8597 32.303 39.8622 32.2738C40.5294 25.3712 38.7447 19.3753 35.131 14.06C35.1221 14.0422 35.107 14.0295 35.0894 14.0219ZM18.6934 28.6371C17.1167 28.6371 15.8175 27.1896 15.8175 25.4119C15.8175 23.6341 17.0915 22.1866 18.6934 22.1866C20.3078 22.1866 21.5944 23.6469 21.5692 25.4119C21.5692 27.1896 20.2952 28.6371 18.6934 28.6371ZM29.3263 28.6371C27.7497 28.6371 26.4505 27.1896 26.4505 25.4119C26.4505 23.6341 27.7244 22.1866 29.3263 22.1866C30.9408 22.1866 32.2274 23.6469 32.2022 25.4119C32.2022 27.1896 30.9408 28.6371 29.3263 28.6371Z" fill="#5865F2"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,4 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
<rect width="48" height="48" rx="12" fill="#191C1D"/>
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 40C32.8366 40 40 32.8366 40 24C40 15.1634 32.8366 8 24 8C15.1634 8 8 15.1634 8 24C8 32.8366 15.1634 40 24 40ZM30 16C30.4747 16 30.9301 16.0827 31.3526 16.2345L24.001 23.5861L16.6488 16.234C17.0709 16.0825 17.5258 16 18 16H30ZM24.7586 25.6569C24.5505 25.865 24.2735 25.9622 24.001 25.9485C23.7285 25.9622 23.4514 25.865 23.2433 25.6569L14.9723 17.3859C14.3665 18.087 14 19.0007 14 20V28C14 30.2091 15.7909 32 18 32H30C32.2091 32 34 30.2091 34 28V20C34 19.0012 33.6339 18.0879 33.0286 17.3869L24.7586 25.6569Z" fill="#8ECF8E"/>
</svg>

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 884 B

View file

@ -1,4 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
<path d="M23.9999 8C20.2007 8.00016 16.5255 9.38638 13.6319 11.9106C10.7384 14.4349 8.81524 17.9324 8.20666 21.7774C7.59807 25.6225 8.34375 29.5641 10.3103 32.897C12.2768 36.2299 15.3358 38.7366 18.94 39.9686C19.74 40.1122 20.04 39.62 20.04 39.1894C20.04 38.7998 20.02 37.5079 20.02 36.134C16 36.8927 14.96 35.1293 14.64 34.2065C14.2849 33.3091 13.722 32.5138 13 31.8893C12.44 31.5817 11.64 30.823 12.98 30.8025C13.4916 30.8595 13.9824 31.042 14.4106 31.3347C14.8388 31.6274 15.1919 32.0216 15.44 32.484C15.6588 32.887 15.9531 33.2419 16.3059 33.5281C16.6587 33.8144 17.0631 34.0264 17.496 34.1522C17.9289 34.2779 18.3817 34.3148 18.8286 34.2608C19.2754 34.2068 19.7074 34.0629 20.0999 33.8373C20.1692 33.0034 20.5317 32.2236 21.12 31.6433C17.56 31.2332 13.84 29.8182 13.84 23.5435C13.8175 21.9131 14.4043 20.3357 15.48 19.1347C14.9908 17.7177 15.0481 16.1627 15.64 14.7875C15.64 14.7875 16.9799 14.3568 20.04 16.469C22.658 15.7307 25.4219 15.7307 28.0399 16.469C31.0998 14.3364 32.4399 14.7875 32.4399 14.7875C33.0319 16.1626 33.0891 17.7177 32.5999 19.1347C33.6788 20.3336 34.2661 21.9124 34.2399 23.5435C34.2399 29.8387 30.4999 31.2332 26.9399 31.6433C27.3217 32.0401 27.6158 32.5165 27.8022 33.0402C27.9885 33.5638 28.0628 34.1225 28.0199 34.6782C28.0199 36.8723 27.9999 38.6357 27.9999 39.1894C27.9999 39.6201 28.2999 40.1327 29.0999 39.9687C32.6977 38.7266 35.748 36.214 37.7061 32.8794C39.6642 29.5448 40.4028 25.6052 39.79 21.7639C39.1772 17.9226 37.2529 14.4296 34.3606 11.9084C31.4683 9.38726 27.7962 8.00203 23.9999 8Z" fill="#B3A6DA"/>
<rect width="48" height="48" rx="12" fill="#191C1D"/>
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
<path d="M23.9999 8C20.2007 8.00016 16.5255 9.38638 13.6319 11.9106C10.7384 14.4349 8.81524 17.9324 8.20666 21.7774C7.59807 25.6225 8.34375 29.5641 10.3103 32.897C12.2768 36.2299 15.3358 38.7366 18.94 39.9686C19.74 40.1122 20.04 39.62 20.04 39.1894C20.04 38.7998 20.02 37.5079 20.02 36.134C16 36.8927 14.96 35.1293 14.64 34.2065C14.2849 33.3091 13.722 32.5138 13 31.8893C12.44 31.5817 11.64 30.823 12.98 30.8025C13.4916 30.8595 13.9824 31.042 14.4106 31.3347C14.8388 31.6274 15.1919 32.0216 15.44 32.484C15.6588 32.887 15.9531 33.2419 16.3059 33.5281C16.6587 33.8144 17.0631 34.0264 17.496 34.1522C17.9289 34.2779 18.3817 34.3148 18.8286 34.2608C19.2754 34.2068 19.7074 34.0629 20.0999 33.8373C20.1692 33.0034 20.5317 32.2236 21.12 31.6433C17.56 31.2332 13.84 29.8182 13.84 23.5435C13.8175 21.9131 14.4043 20.3357 15.48 19.1347C14.9908 17.7177 15.0481 16.1627 15.64 14.7875C15.64 14.7875 16.9799 14.3568 20.04 16.469C22.658 15.7307 25.4219 15.7307 28.0399 16.469C31.0998 14.3364 32.4399 14.7875 32.4399 14.7875C33.0319 16.1626 33.0891 17.7177 32.5999 19.1347C33.6788 20.3336 34.2661 21.9124 34.2399 23.5435C34.2399 29.8387 30.4999 31.2332 26.9399 31.6433C27.3217 32.0401 27.6158 32.5165 27.8022 33.0402C27.9885 33.5638 28.0628 34.1225 28.0199 34.6782C28.0199 36.8723 27.9999 38.6357 27.9999 39.1894C27.9999 39.6201 28.2999 40.1327 29.0999 39.9687C32.6977 38.7266 35.748 36.214 37.7061 32.8794C39.6642 29.5448 40.4028 25.6052 39.79 21.7639C39.1772 17.9226 37.2529 14.4296 34.3606 11.9084C31.4683 9.38726 27.7962 8.00203 23.9999 8Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,5 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.00008 0.666748C7.35191 0.666748 5.74074 1.15549 4.37033 2.07117C2.99992 2.98685 1.93182 4.28834 1.30109 5.81105C0.670359 7.33377 0.505331 9.00933 0.826874 10.6258C1.14842 12.2423 1.94209 13.7272 3.10753 14.8926C4.27297 16.0581 5.75782 16.8517 7.37433 17.1733C8.99084 17.4948 10.6664 17.3298 12.1891 16.6991C13.7118 16.0683 15.0133 15.0002 15.929 13.6298C16.8447 12.2594 17.3334 10.6483 17.3334 9.00008C17.3334 7.90573 17.1179 6.8221 16.6991 5.81105C16.2803 4.80001 15.6665 3.88135 14.8926 3.10752C14.1188 2.3337 13.2002 1.71987 12.1891 1.30109C11.1781 0.882296 10.0944 0.666748 9.00008 0.666748ZM9.00008 15.6667C7.68154 15.6667 6.39261 15.2758 5.29628 14.5432C4.19996 13.8107 3.34547 12.7695 2.84089 11.5513C2.3363 10.3331 2.20428 8.99269 2.46152 7.69948C2.71875 6.40627 3.35369 5.21839 4.28604 4.28604C5.21839 3.35369 6.40628 2.71875 7.69948 2.46151C8.99269 2.20428 10.3331 2.3363 11.5513 2.84088C12.7695 3.34547 13.8107 4.19995 14.5432 5.29628C15.2758 6.39261 15.6668 7.68154 15.6668 9.00008C15.6668 10.7682 14.9644 12.4639 13.7141 13.7141C12.4639 14.9644 10.7682 15.6667 9.00008 15.6667ZM12.3334 8.16675H5.66675C5.44574 8.16675 5.23378 8.25455 5.0775 8.41083C4.92122 8.56711 4.83342 8.77907 4.83342 9.00008C4.83342 9.2211 4.92122 9.43306 5.0775 9.58934C5.23378 9.74562 5.44574 9.83342 5.66675 9.83342H12.3334C12.5544 9.83342 12.7664 9.74562 12.9227 9.58934C13.079 9.43306 13.1668 9.2211 13.1668 9.00008C13.1668 8.77907 13.079 8.56711 12.9227 8.41083C12.7664 8.25455 12.5544 8.16675 12.3334 8.16675Z"
fill="currentColor" />
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.99996 1.66667C8.35179 1.66667 6.74062 2.15541 5.37021 3.07109C3.9998 3.98676 2.9317 5.28825 2.30097 6.81097C1.67024 8.33369 1.50521 10.0092 1.82675 11.6258C2.1483 13.2423 2.94197 14.7271 4.10741 15.8926C5.27284 17.058 6.7577 17.8517 8.37421 18.1732C9.99072 18.4948 11.6663 18.3297 13.189 17.699C14.7117 17.0683 16.0132 16.0002 16.9289 14.6298C17.8446 13.2593 18.3333 11.6482 18.3333 10C18.3333 8.90565 18.1177 7.82202 17.699 6.81097C17.2802 5.79992 16.6663 4.88126 15.8925 4.10744C15.1187 3.33362 14.2 2.71979 13.189 2.301C12.1779 1.88221 11.0943 1.66667 9.99996 1.66667ZM9.99996 16.6667C8.68142 16.6667 7.39249 16.2757 6.29616 15.5431C5.19983 14.8106 4.34535 13.7694 3.84077 12.5512C3.33618 11.333 3.20416 9.9926 3.46139 8.6994C3.71863 7.40619 4.35357 6.21831 5.28592 5.28595C6.21827 4.3536 7.40615 3.71867 8.69936 3.46143C9.99257 3.2042 11.333 3.33622 12.5512 3.8408C13.7694 4.34539 14.8105 5.19987 15.5431 6.2962C16.2756 7.39253 16.6666 8.68146 16.6666 10C16.6666 11.7681 15.9643 13.4638 14.714 14.714C13.4638 15.9643 11.7681 16.6667 9.99996 16.6667ZM13.3333 9.16667H6.66663C6.44562 9.16667 6.23365 9.25446 6.07737 9.41074C5.92109 9.56703 5.8333 9.77899 5.8333 10C5.8333 10.221 5.92109 10.433 6.07737 10.5893C6.23365 10.7455 6.44562 10.8333 6.66663 10.8333H13.3333C13.5543 10.8333 13.7663 10.7455 13.9226 10.5893C14.0788 10.433 14.1666 10.221 14.1666 10C14.1666 9.77899 14.0788 9.56703 13.9226 9.41074C13.7663 9.25446 13.5543 9.16667 13.3333 9.16667Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,3 +1,10 @@
@use '@/scss/underscore' as _;
.content {
padding: _.unit(1);
min-width: 200px;
}
.dropdownTitle {
padding: _.unit(3);
}

View file

@ -42,6 +42,7 @@ const ActionMenu = ({
/>
<Dropdown
title={title}
titleClassName={styles.dropdownTitle}
anchorRef={anchorReference}
isOpen={isOpen}
className={classNames(styles.content, dropdownClassName)}

View file

@ -24,13 +24,6 @@
margin: 0 _.unit(3);
}
.action {
a {
color: var(--color-text);
text-decoration: underline;
}
}
&.info {
background: var(--color-surface-variant);

View file

@ -1,11 +1,12 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import Info from '@/assets/images/info.svg';
import LinkButton from '@/components/LinkButton';
import Button from '../Button';
import TextLink from '../TextLink';
import * as styles from './index.module.scss';
type Props = {
@ -27,17 +28,15 @@ const Alert = ({
variant = 'plain',
className,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={classNames(styles.alert, styles[severity], styles[variant], className)}>
<div className={styles.icon}>
<Info />
</div>
<div className={styles.content}>{children}</div>
{action && href && (
<div className={styles.action}>
<LinkButton title={action} to={href} />
</div>
)}
{action && href && <TextLink to={href}>{t(action)}</TextLink>}
{action && onClick && (
<div className={styles.action}>
<Button title={action} type="text" size="small" onClick={onClick} />

View file

@ -19,9 +19,11 @@ const Contact = ({ isOpen, onCancel }: Props) => {
return (
<ReactModal
shouldCloseOnEsc
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onCancel}
>
<ModalLayout title="contact.title" subtitle="contact.description" onClose={onCancel}>
<div className={styles.main}>

View file

@ -7,6 +7,7 @@
flex-shrink: 0;
width: 248px;
overflow-y: auto;
margin-bottom: _.unit(6);
> div + div {
margin-top: _.unit(6);

View file

@ -42,11 +42,12 @@
}
.dropdown {
padding: _.unit(2);
padding: _.unit(1);
}
.dropdownItem {
min-width: 170px;
padding: _.unit(2.5) _.unit(2);
&.loading {
opacity: 60%;

View file

@ -26,11 +26,7 @@ const UserInfo = () => {
(async () => {
if (isAuthenticated) {
const userInfo = await getIdTokenClaims();
// TODO: revert after SDK updated
setUser({
picture: undefined,
...(userInfo ?? { sub: '', username: 'N/A' }),
}); // Provide a fallback to avoid infinite loading state
setUser(userInfo ?? { sub: '', username: 'N/A' }); // Provide a fallback to avoid infinite loading state
}
})();
}, [isAuthenticated, getIdTokenClaims]);
@ -55,8 +51,7 @@ const UserInfo = () => {
setShowDropdown(true);
}}
>
{/* TODO: revert after SDK updated */}
<img src={picture ? String(picture) : generateAvatarPlaceHolderById(id)} alt="avatar" />
<img src={picture ?? generateAvatarPlaceHolderById(id)} alt="avatar" />
<div className={styles.wrapper}>
<div className={styles.name}>{username}</div>
</div>

View file

@ -15,13 +15,12 @@
.content {
flex-grow: 1;
display: flex;
margin-bottom: _.unit(6);
overflow: hidden;
}
.main {
flex-grow: 1;
padding-right: _.unit(6);
padding: 0 _.unit(2);
overflow-y: scroll;
> * {

View file

@ -1,9 +1,19 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.filter {
display: flex;
justify-content: right;
justify-content: flex-end;
align-items: center;
padding: _.unit(3);
border-bottom: 1px solid var(--color-divider);
background-color: var(--color-layer-1);
border-radius: 12px 12px 0 0;
.title {
color: var(--color-text-secondary);
@ -21,14 +31,21 @@
}
}
.table {
margin-top: _.unit(4);
.tableLayout {
display: flex;
flex-direction: column;
max-height: 100%;
overflow-y: auto;
flex: 1;
.tableContainer {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
.pagination {
margin-top: _.unit(4);
min-height: 32px;
}
.eventName {

View file

@ -1,6 +1,6 @@
import type { LogDto } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import { conditional, conditionalString } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
@ -26,6 +26,8 @@ type Props = {
userId?: string;
};
const defaultTableColumn = 4;
const AuditLogTable = ({ userId }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
@ -49,6 +51,7 @@ const AuditLogTable = ({ userId }: Props) => {
const navigate = useNavigate();
const [logs, totalCount] = data ?? [];
const showUserColumn = !userId;
const tableColumnCount = showUserColumn ? defaultTableColumn : defaultTableColumn - 1;
const updateQuery = (key: string, value: string) => {
const queries: Record<string, string> = {};
@ -65,91 +68,91 @@ const AuditLogTable = ({ userId }: Props) => {
};
return (
<>
<div className={styles.filter}>
<div className={styles.title}>{t('logs.filter_by')}</div>
<div className={styles.eventSelector}>
<EventSelector
value={event ?? undefined}
onChange={(value) => {
updateQuery('event', value ?? '');
}}
/>
<div className={styles.container}>
<div className={styles.tableLayout}>
<div className={styles.filter}>
<div className={styles.title}>{t('logs.filter_by')}</div>
<div className={styles.eventSelector}>
<EventSelector
value={event ?? undefined}
onChange={(value) => {
updateQuery('event', value ?? '');
}}
/>
</div>
<div className={styles.applicationSelector}>
<ApplicationSelector
value={applicationId ?? undefined}
onChange={(value) => {
updateQuery('applicationId', value ?? '');
}}
/>
</div>
</div>
<div className={styles.applicationSelector}>
<ApplicationSelector
value={applicationId ?? undefined}
onChange={(value) => {
updateQuery('applicationId', value ?? '');
}}
/>
</div>
</div>
<div className={classNames(styles.table, tableStyles.scrollable)}>
<table className={classNames(logs?.length === 0 && tableStyles.empty)}>
<colgroup>
<col className={styles.eventName} />
{showUserColumn && <col />}
<col />
<col />
</colgroup>
<thead>
<tr>
<th>{t('logs.event')}</th>
{showUserColumn && <th>{t('logs.user')}</th>}
<th>{t('logs.application')}</th>
<th>{t('logs.time')}</th>
</tr>
</thead>
<tbody>
{!data && error && (
<TableError
columns={4}
content={error.body?.message ?? error.message}
onRetry={async () => mutate(undefined, true)}
/>
)}
{isLoading && <TableLoading columns={4} />}
{logs?.length === 0 && <TableEmpty columns={4} />}
{logs?.map(({ type, payload, createdAt, id }) => (
<tr
key={id}
className={tableStyles.clickable}
onClick={() => {
navigate(`${pathname}/${id}`);
}}
>
<td>
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
</td>
{showUserColumn && (
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
)}
<td>
{payload.applicationId ? (
<ApplicationName applicationId={payload.applicationId} />
) : (
'-'
)}
</td>
<td>{new Date(createdAt).toLocaleString()}</td>
<div className={classNames(tableStyles.scrollable, styles.tableContainer)}>
<table className={conditional(logs?.length === 0 && tableStyles.empty)}>
<colgroup>
<col className={styles.eventName} />
{showUserColumn && <col />}
<col />
<col />
</colgroup>
<thead>
<tr>
<th>{t('logs.event')}</th>
{showUserColumn && <th>{t('logs.user')}</th>}
<th>{t('logs.application')}</th>
<th>{t('logs.time')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{!data && error && (
<TableError
columns={tableColumnCount}
content={error.body?.message ?? error.message}
onRetry={async () => mutate(undefined, true)}
/>
)}
{isLoading && <TableLoading columns={tableColumnCount} />}
{logs?.length === 0 && <TableEmpty columns={tableColumnCount} />}
{logs?.map(({ type, payload, createdAt, id }) => (
<tr
key={id}
className={tableStyles.clickable}
onClick={() => {
navigate(`${pathname}/${id}`);
}}
>
<td>
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
</td>
{showUserColumn && (
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
)}
<td>
{payload.applicationId ? (
<ApplicationName applicationId={payload.applicationId} />
) : (
'-'
)}
</td>
<td>{new Date(createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className={styles.pagination}>
{!!totalCount && (
<Pagination
pageCount={Math.ceil(totalCount / pageSize)}
pageIndex={pageIndex}
onChange={(page) => {
updateQuery('page', String(page));
}}
/>
)}
</div>
</>
<Pagination
pageIndex={pageIndex}
totalCount={totalCount ?? 0}
pageSize={pageSize}
className={styles.pagination}
onChange={(page) => {
updateQuery('page', String(page));
}}
/>
</div>
);
};

View file

@ -40,15 +40,20 @@
}
.icon {
display: block;
width: 20px;
height: 20px;
display: flex;
align-items: center;
&:not(:last-child) {
margin-right: _.unit(2);
}
}
.trailingIcon {
display: block;
width: 16px;
height: 16px;
}
&.small {
height: 30px;
padding: 0 _.unit(3);
@ -56,6 +61,12 @@
&.text {
height: 24px;
}
.icon {
&:not(:last-child) {
margin-right: _.unit(1);
}
}
}
&.medium {

View file

@ -17,6 +17,7 @@ type BaseProps = Omit<HTMLProps<HTMLButtonElement>, 'type' | 'size' | 'title'> &
size?: 'small' | 'medium' | 'large';
isLoading?: boolean;
loadingDelay?: number;
trailingIcon?: ReactNode;
};
type TitleButtonProps = BaseProps & {
@ -41,6 +42,7 @@ const Button = ({
isLoading = false,
loadingDelay = 500,
onClick,
trailingIcon,
...rest
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -84,6 +86,7 @@ const Button = ({
{showSpinner && <Spinner className={styles.spinner} />}
{icon && <span className={styles.icon}>{icon}</span>}
{title && (typeof title === 'string' ? <span>{t(title)}</span> : title)}
{trailingIcon && <span className={styles.trailingIcon}>{trailingIcon}</span>}
</button>
);
};

View file

@ -10,16 +10,17 @@ type Props = {
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
subtitle?: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
size?: 'small' | 'medium' | 'large';
className?: string;
};
/**
* Always use this component to render CardTitle, with built-in i18n support.
*/
const CardTitle = ({ title, subtitle, size = 'large' }: Props) => {
const CardTitle = ({ title, subtitle, size = 'large', className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={classNames(styles.container, styles[size])}>
<div className={classNames(styles.container, styles[size], className)}>
<div className={styles.title}>{typeof title === 'string' ? t(title) : title}</div>
{subtitle && (
<div className={styles.subtitle}>

View file

@ -1,9 +1,9 @@
import classNames from 'classnames';
import { nanoid } from 'nanoid';
import type { ReactNode } from 'react';
import { useRef, useState } from 'react';
import { useState } from 'react';
import Tooltip from '../Tooltip';
import { Tooltip } from '../Tip';
import Icon from './Icon';
import * as styles from './index.module.scss';
@ -21,8 +21,6 @@ type Props = {
const Checkbox = ({ value, onChange, label, disabled, className, disabledTooltip }: Props) => {
const [id, setId] = useState(nanoid());
const tipRef = useRef<HTMLDivElement>(null);
return (
<div className={classNames(styles.checkbox, className)}>
<input
@ -35,10 +33,11 @@ const Checkbox = ({ value, onChange, label, disabled, className, disabledTooltip
}}
/>
{disabled && disabledTooltip && (
<>
<div ref={tipRef} className={styles.disabledMask} />
<Tooltip anchorRef={tipRef} content={disabledTooltip} />
</>
<Tooltip
horizontalAlign="start"
anchorClassName={styles.disabledMask}
content={disabledTooltip}
/>
)}
<Icon className={styles.icon} />
{label && <label htmlFor={id}>{label}</label>}

View file

@ -22,6 +22,7 @@
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--color-divider);
border-radius: 4px;
overflow: hidden;
@ -47,9 +48,7 @@
}
label {
display: flex;
align-items: center;
position: relative;
flex: 1;
margin-left: _.unit(2);
}
}

View file

@ -39,15 +39,17 @@ const ConfirmModal = ({
}: ConfirmModalProps) => {
return (
<ReactModal
shouldCloseOnEsc
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onCancel}
>
<ModalLayout
title={title}
footer={
<>
<Button type="outline" title={cancelButtonText} onClick={onCancel} />
<Button title={cancelButtonText} onClick={onCancel} />
<Button
type={confirmButtonType}
title={confirmButtonText}

View file

@ -9,7 +9,7 @@
cursor: default;
&.contained {
padding: _.unit(1) _.unit(1) _.unit(1) _.unit(3);
padding: _.unit(1) _.unit(2);
background: var(--color-layer-2);
}
@ -31,12 +31,36 @@
text-overflow: ellipsis;
}
.copyIcon {
margin-left: _.unit(3);
svg {
width: 16px;
height: 16px;
.copyToolTipAnchor {
margin-left: _.unit(2);
}
}
&.default {
.row {
.copyToolTipAnchor {
margin-left: _.unit(3);
}
}
}
&.small {
.row {
.copyToolTipAnchor {
margin-left: _.unit(1);
}
.iconButton {
height: 20px;
width: 20px;
.icon {
svg {
width: 12px;
height: 12px;
}
}
}
}
}

View file

@ -10,7 +10,7 @@ import Eye from '@/assets/images/eye.svg';
import { onKeyDownHandler } from '@/utilities/a11y';
import IconButton from '../IconButton';
import Tooltip from '../Tooltip';
import { Tooltip } from '../Tip';
import * as styles from './index.module.scss';
type Props = {
@ -18,6 +18,7 @@ type Props = {
className?: string;
variant?: 'text' | 'contained' | 'border' | 'icon';
hasVisibilityToggle?: boolean;
size?: 'default' | 'small';
};
type CopyState = TFuncKey<'translation', 'admin_console.general'>;
@ -27,8 +28,9 @@ const CopyToClipboard = ({
className,
hasVisibilityToggle,
variant = 'contained',
size = 'default',
}: Props) => {
const copyIconReference = useRef<HTMLDivElement>(null);
const copyIconReference = useRef<HTMLButtonElement>(null);
const [copyState, setCopyState] = useState<CopyState>('copy');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.general' });
const [showHiddenContent, setShowHiddenContent] = useState(false);
@ -59,7 +61,7 @@ const CopyToClipboard = ({
return (
<div
className={classNames(styles.container, styles[variant], className)}
className={classNames(styles.container, styles[variant], styles[size], className)}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler((event) => {
@ -72,23 +74,28 @@ const CopyToClipboard = ({
<div className={styles.row}>
{variant !== 'icon' && <div className={styles.content}>{displayValue}</div>}
{hasVisibilityToggle && (
<div className={styles.eye}>
<IconButton onClick={toggleHiddenContent}>
{showHiddenContent ? <EyeClosed /> : <Eye />}
</IconButton>
</div>
<IconButton
className={styles.iconButton}
iconClassName={styles.icon}
onClick={toggleHiddenContent}
>
{showHiddenContent ? <EyeClosed /> : <Eye />}
</IconButton>
)}
<div ref={copyIconReference} className={styles.copyIcon}>
<IconButton onClick={copy}>
<Tooltip
className={classNames(copyState === 'copied' && styles.successfulTooltip)}
anchorClassName={styles.copyToolTipAnchor}
content={t(copyState)}
>
<IconButton
ref={copyIconReference}
className={styles.iconButton}
iconClassName={styles.icon}
onClick={copy}
>
<Copy />
</IconButton>
</div>
<Tooltip
anchorRef={copyIconReference}
content={t(copyState)}
horizontalAlign="center"
className={classNames(copyState === 'copied' && styles.successfulTooltip)}
/>
</Tooltip>
</div>
</div>
);

View file

@ -0,0 +1,24 @@
@use '@/scss/underscore' as _;
.container {
flex-grow: 1;
display: flex;
flex-direction: column;
padding-bottom: _.unit(2);
&.withSubmitActionBar {
padding-bottom: 0;
}
>:not(:first-child) {
margin-top: _.unit(4);
}
.fields {
flex-grow: 1;
> :not(:first-child) {
margin-top: _.unit(4);
}
}
}

View file

@ -0,0 +1,29 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import SubmitFormChangesActionBar from '../SubmitFormChangesActionBar';
import * as styles from './index.module.scss';
type Props = {
isDirty: boolean;
isSubmitting: boolean;
children: ReactNode;
onSubmit: () => Promise<void>;
onDiscard: () => void;
};
const DetailsForm = ({ isDirty, isSubmitting, onSubmit, onDiscard, children }: Props) => {
return (
<form className={classNames(styles.container, isDirty && styles.withSubmitActionBar)}>
<div className={styles.fields}>{children}</div>
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSubmitting}
onSubmit={onSubmit}
onDiscard={onDiscard}
/>
</form>
);
};
export default DetailsForm;

View file

@ -22,7 +22,6 @@
.title {
@include _.subhead-cap;
padding: _.unit(3);
}
.overlay {
@ -33,7 +32,5 @@
.list {
margin: 0;
padding: _.unit(1);
max-height: 288px;
overflow-y: auto;
}

View file

@ -0,0 +1,46 @@
@use '@/scss/underscore' as _;
.container {
padding: _.unit(6) _.unit(8);
display: flex;
}
.introduction {
width: 296px;
padding-bottom: _.unit(6);
margin-right: _.unit(14);
flex-shrink: 0;
> :not(:first-child) {
margin-top: _.unit(2);
}
.title {
@include _.subhead-cap;
color: var(--color-neutral-variant-60);
}
.description {
font: var(--font-body-medium);
color: var(--color-text-secondary);
.link {
margin-left: _.unit(1);
}
}
}
@media screen and (max-width: 1080px) {
.container {
flex-direction: column;
.introduction {
width: 100%;
margin-right: unset;
}
}
}
.form {
flex-grow: 1;
}

View file

@ -0,0 +1,39 @@
import type { AdminConsoleKey } from '@logto/phrases';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import Card from '../Card';
import TextLink from '../TextLink';
import * as styles from './index.module.scss';
type Props = {
title: AdminConsoleKey;
description?: AdminConsoleKey;
learnMoreLink?: string;
children: ReactNode;
};
const FormCard = ({ title, description, learnMoreLink, children }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<Card className={styles.container}>
<div className={styles.introduction}>
<div className={styles.title}>{t(title)}</div>
{description && (
<div className={styles.description}>
{t(description)}
{learnMoreLink && (
<TextLink href={learnMoreLink} target="_blank" rel="noopener" className={styles.link}>
{t('general.learn_more')}
</TextLink>
)}
</div>
)}
</div>
<div className={styles.form}>{children}</div>
</Card>
);
};
export default FormCard;

View file

@ -16,11 +16,8 @@
color: var(--color-text);
}
.icon {
margin-left: _.unit(1);
width: 16px;
height: 16px;
color: var(--color-text-secondary);
.toggleTipButton {
margin-left: _.unit(0.5);
}
.required {

View file

@ -1,37 +1,39 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactElement, ReactNode } from 'react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import Tip from '@/assets/images/tip.svg';
import type DangerousRaw from '../DangerousRaw';
import IconButton from '../IconButton';
import Spacer from '../Spacer';
import Tooltip from '../Tooltip';
import { ToggleTip } from '../Tip';
import type { Props as ToggleTipProps } from '../Tip/ToggleTip';
import * as styles from './index.module.scss';
type Props = {
export type Props = {
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
children: ReactNode;
isRequired?: boolean;
className?: string;
tooltip?: AdminConsoleKey;
headlineClassName?: string;
tip?: ToggleTipProps['content'];
};
const FormField = ({ title, children, isRequired, className, tooltip }: Props) => {
const FormField = ({ title, children, isRequired, className, tip, headlineClassName }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tipRef = useRef<HTMLDivElement>(null);
return (
<div className={classNames(styles.field, className)}>
<div className={styles.headline}>
<div className={classNames(styles.headline, headlineClassName)}>
<div className={styles.title}>{typeof title === 'string' ? t(title) : title}</div>
{tooltip && (
<div ref={tipRef} className={styles.icon}>
<Tip />
<Tooltip anchorRef={tipRef} content={t(tooltip)} />
</div>
{tip && (
<ToggleTip anchorClassName={styles.toggleTipButton} content={tip}>
<IconButton size="small">
<Tip />
</IconButton>
</ToggleTip>
)}
<Spacer />
{isRequired && <div className={styles.required}>{t('general.required')}</div>}

View file

@ -1,17 +1,16 @@
import classNames from 'classnames';
import type { ForwardedRef, HTMLProps, ReactNode } from 'react';
import type { ForwardedRef, HTMLProps } from 'react';
import { forwardRef, useRef } from 'react';
import Tooltip from '../Tooltip';
import * as styles from './index.module.scss';
export type Props = Omit<HTMLProps<HTMLButtonElement>, 'size' | 'type'> & {
size?: 'small' | 'medium' | 'large';
tooltip?: ReactNode;
iconClassName?: string;
};
const IconButton = (
{ size = 'medium', children, className, tooltip, ...rest }: Props,
{ size = 'medium', children, className, iconClassName, ...rest }: Props,
reference: ForwardedRef<HTMLButtonElement>
) => {
const tipRef = useRef<HTMLDivElement>(null);
@ -23,12 +22,9 @@ const IconButton = (
className={classNames(styles.button, styles[size], className)}
{...rest}
>
<div ref={tipRef} className={styles.icon}>
<div ref={tipRef} className={classNames(styles.icon, iconClassName)}>
{children}
</div>
{tooltip && (
<Tooltip anchorRef={tipRef} content={tooltip} position="top" horizontalAlign="center" />
)}
</button>
);
};

View file

@ -1,28 +0,0 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { ReactElement, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type DangerousRaw from '../DangerousRaw';
import * as styles from './index.module.scss';
type Props = {
to: string;
title: AdminConsoleKey | ReactElement<typeof DangerousRaw>;
icon?: ReactNode;
className?: string;
};
const LinkButton = ({ to, title, icon, className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<Link to={to} className={classNames(styles.linkButton, className)}>
{icon}
{typeof title === 'string' ? <span>{t(title)}</span> : title}
</Link>
);
};
export default LinkButton;

View file

@ -34,9 +34,14 @@
font: var(--font-body-medium);
color: var(--color-text-link);
text-decoration: none;
text-underline-offset: 2px;
&:hover {
border-bottom: 1px solid var(--color-text-link);
text-decoration: underline;
}
&:active {
color: var(--color-primary-pressed);
}
}

View file

@ -8,7 +8,6 @@
min-width: dim.$modal-layout-width-small;
padding: _.unit(6);
margin: 0 _.unit(6);
border: 1px solid var(--color-divider);
box-shadow: var(--shadow-3);
.header {

View file

@ -5,14 +5,20 @@
margin-top: _.unit(2);
}
.firstFieldWithMultiInputs {
padding-right: _.unit(9);
}
.deletableInput {
display: flex;
align-items: center;
> :first-child {
@include _.form-text-field;
margin-right: _.unit(2);
flex-shrink: 0;
flex: 1;
}
> :not(:first-child) {
margin-left: _.unit(2);
}
}

View file

@ -1,8 +1,11 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import type { KeyboardEvent } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import CirclePlus from '@/assets/images/circle-plus.svg';
import Minus from '@/assets/images/minus.svg';
import Button from '../Button';
@ -12,16 +15,25 @@ import TextInput from '../TextInput';
import * as styles from './index.module.scss';
import type { MultiTextInputError } from './types';
type Props = {
export type Props = {
title: AdminConsoleKey;
value?: string[];
onChange: (value: string[]) => void;
onKeyPress?: (event: KeyboardEvent<HTMLInputElement>) => void;
error?: MultiTextInputError;
placeholder?: string;
className?: string;
};
const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder }: Props) => {
const MultiTextInput = ({
title,
value,
onChange,
onKeyPress,
error,
placeholder,
className,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [deleteFieldIndex, setDeleteFieldIndex] = useState<number>();
@ -47,10 +59,15 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder
};
return (
<div className={styles.multilineInput}>
<div className={classNames(styles.multilineInput, className)}>
{fields.map((fieldValue, fieldIndex) => (
// eslint-disable-next-line react/no-array-index-key
<div key={fieldIndex}>
<div
// eslint-disable-next-line react/no-array-index-key
key={fieldIndex}
className={conditional(
fields.length > 1 && fieldIndex === 0 && styles.firstFieldWithMultiInputs
)}
>
<div className={styles.deletableInput}>
<TextInput
hasError={Boolean(
@ -90,6 +107,7 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder
type="text"
title="general.add_another"
className={styles.addAnother}
icon={<CirclePlus />}
onClick={handleAdd}
/>
<ConfirmModal

View file

@ -0,0 +1,5 @@
@use '@/scss/underscore' as _;
.headlineWithMultiInputs {
padding-right: _.unit(9);
}

View file

@ -0,0 +1,34 @@
import { conditional } from '@silverhand/essentials';
import type { Props as FormFieldProps } from '@/components/FormField';
import FormField from '@/components/FormField';
import type { Props as MultiTextInputProps } from '@/components/MultiTextInput';
import MultiTextInput from '../MultiTextInput';
import * as styles from './index.module.scss';
type Props = MultiTextInputProps &
Pick<FormFieldProps, 'isRequired' | 'tip'> & {
formFieldClassName?: FormFieldProps['className'];
};
const MultiTextInputField = ({
title,
isRequired,
tip,
formFieldClassName,
value,
...rest
}: Props) => (
<FormField
title={title}
isRequired={isRequired}
tip={tip}
className={formFieldClassName}
headlineClassName={conditional(value && value.length > 1 && styles.headlineWithMultiInputs)}
>
<MultiTextInput title={title} value={value} {...rest} />
</FormField>
);
export default MultiTextInputField;

View file

@ -1,9 +1,22 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
justify-content: flex-end;
align-items: center;
}
.positionInfo {
font: var(--font-body-medium);
color: var(--color-text-secondary);
}
.pagination {
display: flex;
justify-content: right;
margin: 0;
height: 28px;
padding-inline-start: _.unit(4);
li {
list-style: none;

View file

@ -1,4 +1,5 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import ReactPaginate from 'react-paginate';
import Button from '../Button';
@ -9,34 +10,51 @@ import * as styles from './index.module.scss';
type Props = {
pageIndex: number;
pageCount: number;
totalCount: number;
pageSize: number;
className?: string;
onChange?: (pageIndex: number) => void;
};
const Pagination = ({ pageIndex, pageCount, onChange }: Props) => {
const Pagination = ({ pageIndex, totalCount, pageSize, className, onChange }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const pageCount = Math.ceil(totalCount / pageSize);
if (pageCount <= 1) {
return null;
}
const min = (pageIndex - 1) * pageSize + 1;
const max = Math.min(pageIndex * pageSize, totalCount);
return (
<ReactPaginate
className={styles.pagination}
pageCount={pageCount}
forcePage={pageIndex - 1}
pageLabelBuilder={(page: number) => (
<Button
type={page === pageIndex ? 'outline' : 'default'}
className={classNames(styles.button, page === pageIndex && styles.active)}
size="small"
title={<DangerousRaw>{page}</DangerousRaw>}
/>
)}
previousLabel={<Button className={styles.button} size="small" icon={<Previous />} />}
nextLabel={<Button className={styles.button} size="small" icon={<Next />} />}
breakLabel={
<Button className={styles.button} size="small" title={<DangerousRaw>...</DangerousRaw>} />
}
disabledClassName={styles.disabled}
onPageChange={({ selected }) => {
onChange?.(selected + 1);
}}
/>
<div className={classNames(styles.container, className)}>
<div className={styles.positionInfo}>
{t('general.page_info', { min, max, total: totalCount })}
</div>
<ReactPaginate
className={styles.pagination}
pageCount={pageCount}
forcePage={pageIndex - 1}
pageLabelBuilder={(page: number) => (
<Button
type={page === pageIndex ? 'outline' : 'default'}
className={classNames(styles.button, page === pageIndex && styles.active)}
size="small"
title={<DangerousRaw>{page}</DangerousRaw>}
/>
)}
previousLabel={<Button className={styles.button} size="small" icon={<Previous />} />}
nextLabel={<Button className={styles.button} size="small" icon={<Next />} />}
breakLabel={
<Button className={styles.button} size="small" title={<DangerousRaw>...</DangerousRaw>} />
}
disabledClassName={styles.disabled}
onPageChange={({ selected }) => {
onChange?.(selected + 1);
}}
/>
</div>
);
};

View file

@ -26,6 +26,8 @@ const RadioGroup = (
return child;
}
// FIXME: @Charles
// @ts-expect-error to be fixed
return cloneElement<RadioProps>(child, {
name,
isChecked: value === child.props.value,

View file

@ -15,9 +15,10 @@
font: var(--font-body-medium);
cursor: pointer;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.title {
@include _.text-ellipsis;
}
&.open {
border-color: var(--color-primary);
@ -78,3 +79,8 @@
height: 36px;
}
}
.dropdown {
padding: _.unit(1);
max-height: 288px;
}

View file

@ -80,7 +80,7 @@ const Select = <T extends string>({
}
}}
>
{current?.title ?? placeholder}
<div className={styles.title}>{current?.title ?? placeholder}</div>
{isClearable && (
<IconButton
className={classNames(styles.icon, styles.clear)}
@ -97,6 +97,7 @@ const Select = <T extends string>({
<Dropdown
isFullWidth
anchorRef={anchorRef}
className={styles.dropdown}
isOpen={isOpen}
onClose={() => {
setIsOpen(false);

View file

@ -0,0 +1,37 @@
@use '@/scss/underscore' as _;
.container {
position: sticky;
bottom: 0;
width: 100%;
height: 0;
overflow: hidden;
transition: height 0.3s ease-out 0.1s;
.actionBar {
height: 60px;
border: 1px solid var(--color-line-divider);
display: flex;
padding: _.unit(3) _.unit(8);
justify-content: flex-end;
background-color: var(--color-float);
box-shadow: var(--shadow-3);
border-radius: 12px 12px 0 0;
transform: translateY(100%);
transition: transform 0.3s ease-out;
> button + button {
margin-left: _.unit(3);
}
}
&.active {
height: 60px;
overflow: visible;
.actionBar {
transform: translateY(0);
transition: transform 0.3s ease-in;
}
}
}

View file

@ -0,0 +1,35 @@
import classNames from 'classnames';
import Button from '../Button';
import * as styles from './index.module.scss';
type Props = {
isOpen: boolean;
isSubmitting: boolean;
onSubmit: () => Promise<void>;
onDiscard: () => void;
};
const SubmitFormChangesActionBar = ({ isOpen, isSubmitting, onSubmit, onDiscard }: Props) => (
<div className={classNames(styles.container, isOpen && styles.active)}>
<div className={styles.actionBar}>
<Button
size="medium"
title="general.discard"
disabled={isSubmitting}
onClick={() => {
onDiscard();
}}
/>
<Button
isLoading={isSubmitting}
type="primary"
size="medium"
title="general.save_changes"
onClick={async () => onSubmit()}
/>
</div>
</div>
);
export default SubmitFormChangesActionBar;

View file

@ -1,27 +1,62 @@
@use '@/scss/underscore' as _;
.link {
font: var(--font-subhead-2);
.item {
display: flex;
align-items: center;
&:not(:last-child) {
margin-right: _.unit(6);
}
a {
display: inline-block;
color: var(--color-text-secondary);
text-decoration: none;
cursor: pointer;
padding-bottom: _.unit(1);
.link {
font: var(--font-subhead-2);
padding: _.unit(0.5) _.unit(1.5);
margin-bottom: _.unit(1);
border-radius: 4px;
a {
display: inline-block;
color: var(--color-neutral-30);
text-decoration: none;
cursor: pointer;
}
&:hover {
background-color: var(--color-hover-variant);
}
}
}
.selected {
color: var(--color-text-link);
border-bottom: 2px solid var(--color-text-link);
margin-bottom: -1px;
a {
.selected {
position: relative;
color: var(--color-text-link);
a {
color: var(--color-text-link);
}
&::after {
content: '';
display: block;
position: absolute;
// Note: link item's margin-bottom (_.unit(1)) + TabNav's border-bottom width (1px)
bottom: -5px;
left: 0;
right: 0;
border-top: 2px solid var(--color-text-link);
border-radius: 8px 8px 0 0;
}
}
.errors {
margin-left: _.unit(0.5);
font: var(--font-label-medium);
color: var(--color-white);
padding: _.unit(0.5) _.unit(1.5);
background-color: var(--color-error-50);
border-radius: 10px;
vertical-align: middle;
margin-bottom: _.unit(1);
cursor: default;
}
}

View file

@ -1,30 +1,48 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { onKeyDownHandler } from '@/utilities/a11y';
import * as styles from './TabNavItem.module.scss';
type Props = {
href?: string;
type BaseProps = {
isActive?: boolean;
onClick?: () => void;
errorCount?: number;
children: React.ReactNode;
};
const TabNavItem = ({ children, href, isActive, onClick }: Props) => {
type LinkStyleProps = {
href: string;
};
type TabStyleProps = {
onClick: () => void;
};
type Props =
| (BaseProps & LinkStyleProps & Partial<Record<keyof TabStyleProps, undefined>>)
| (BaseProps & TabStyleProps & Partial<Record<keyof LinkStyleProps, undefined>>);
const TabNavItem = ({ children, href, isActive, errorCount = 0, onClick }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const location = useLocation();
const selected = href ? location.pathname === href : isActive;
return (
<div className={classNames(styles.link, selected && styles.selected)}>
{href ? (
<Link to={href}>{children}</Link>
) : (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a role="tab" tabIndex={0} onKeyDown={onKeyDownHandler(onClick)} onClick={onClick}>
{children}
</a>
<div className={styles.item}>
<div className={classNames(styles.link, selected && styles.selected)}>
{href ? (
<Link to={href}>{children}</Link>
) : (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a role="tab" tabIndex={0} onKeyDown={onKeyDownHandler(onClick)} onClick={onClick}>
{children}
</a>
)}
</div>
{errorCount > 0 && (
<div className={styles.errors}>{t('general.tab_errors', { count: errorCount })}</div>
)}
</div>
);

View file

@ -1,7 +1,7 @@
@use '@/scss/underscore' as _;
.nav {
border-bottom: 1px solid var(--color-divider);
border-bottom: 1px solid var(--color-surface-5);
display: flex;
margin: _.unit(1) 0;
margin-top: _.unit(1);
}

View file

@ -1,21 +1,20 @@
@use '@/scss/underscore' as _;
.linkButton {
background: none;
.link {
display: inline-flex;
max-width: fit-content;
text-decoration: none;
border-color: transparent;
font: var(--font-body-medium);
color: var(--color-text-link);
text-decoration: none;
display: inline-flex;
align-items: center;
user-select: none;
gap: _.unit(1);
> *:not(:first-child) {
margin-left: _.unit(1);
&.trailingIcon {
flex-direction: row-reverse;
}
&:focus-visible {
outline: 2px solid var(--color-focused-variant);
&:active {
color: var(--color-primary-pressed);
}
&:disabled {
@ -25,9 +24,11 @@
&:not(:disabled):hover {
text-decoration: underline;
text-underline-offset: 2px;
}
> svg {
color: var(--color-text-link);
display: inline-block;
vertical-align: baseline;
}
}

View file

@ -0,0 +1,41 @@
import classNames from 'classnames';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import * as styles from './index.module.scss';
type Props = AnchorHTMLAttributes<HTMLAnchorElement> &
Partial<LinkProps> & {
icon?: ReactNode;
isTrailingIcon?: boolean;
};
const TextLink = ({ to, children, icon, isTrailingIcon = false, className, ...rest }: Props) => {
if (to) {
return (
<Link
to={to}
className={classNames(styles.link, isTrailingIcon && styles.trailingIcon, className)}
{...rest}
>
{icon}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<>{children}</>
</Link>
);
}
return (
<a
className={classNames(styles.link, isTrailingIcon && styles.trailingIcon, className)}
{...rest}
>
{icon}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<>{children}</>
</a>
);
};
export default TextLink;

View file

@ -10,6 +10,14 @@
font: var(--font-body-medium);
max-width: 300px;
a {
color: #cabeff;
&:active {
color: #cabeff;
}
}
&::after {
content: '';
display: block;

View file

@ -0,0 +1,104 @@
import type { ReactNode } from 'react';
import { useCallback, useState, useRef } from 'react';
import ReactModal from 'react-modal';
import type { HorizontalAlignment } from '@/hooks/use-position';
import usePosition from '@/hooks/use-position';
import { onKeyDownHandler } from '@/utilities/a11y';
import type { TipBubblePosition } from '../TipBubble';
import TipBubble from '../TipBubble';
import {
getVerticalAlignment,
getHorizontalAlignment,
getVerticalOffset,
getHorizontalOffset,
} from '../TipBubble/utils';
import * as styles from './index.module.scss';
export type Props = {
children: ReactNode;
className?: string;
anchorClassName?: string;
position?: TipBubblePosition;
horizontalAlign?: HorizontalAlignment;
content?: ((closeTip: () => void) => ReactNode) | ReactNode;
};
const ToggleTip = ({
children,
className,
anchorClassName,
position = 'top',
horizontalAlign = 'center',
content,
}: Props) => {
const overlayRef = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const onClose = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
const {
position: layoutPosition,
positionState,
mutate,
} = usePosition({
verticalAlign: getVerticalAlignment(position),
horizontalAlign: getHorizontalAlignment(position, horizontalAlign),
offset: {
vertical: getVerticalOffset(position),
horizontal: getHorizontalOffset(position, horizontalAlign),
},
anchorRef,
overlayRef,
});
return (
<>
<div
ref={anchorRef}
role="tab"
tabIndex={0}
className={anchorClassName}
onClick={() => {
setIsOpen(true);
}}
onKeyDown={onKeyDownHandler(() => {
setIsOpen(true);
})}
>
{children}
</div>
<ReactModal
shouldCloseOnOverlayClick
shouldCloseOnEsc
isOpen={isOpen}
style={{
content: {
...(!layoutPosition && { opacity: 0 }),
...layoutPosition,
},
}}
className={styles.content}
overlayClassName={styles.overlay}
onRequestClose={onClose}
onAfterOpen={mutate}
>
<TipBubble
ref={overlayRef}
position={position}
className={className}
horizontalAlignment={positionState.horizontalAlign}
>
{typeof content === 'function' ? content(onClose) : content}
</TipBubble>
</ReactModal>
</>
);
};
export default ToggleTip;

View file

@ -1,4 +1,4 @@
import type { ReactNode, RefObject } from 'react';
import type { ReactNode } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@ -16,23 +16,26 @@ import {
import * as styles from './index.module.scss';
type Props = {
content: ReactNode | Record<string, unknown>;
anchorRef: RefObject<Element>;
className?: string;
isKeepOpen?: boolean;
position?: TipBubblePosition;
horizontalAlign?: HorizontalAlignment;
anchorClassName?: string;
children?: ReactNode;
content?: ReactNode;
};
const Tooltip = ({
content,
anchorRef,
className,
isKeepOpen = false,
position = 'top',
horizontalAlign = 'start',
horizontalAlign = 'center',
anchorClassName,
children,
content,
}: Props) => {
const [tooltipDom, setTooltipDom] = useState<HTMLDivElement>();
const anchorRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const {
@ -119,25 +122,30 @@ const Tooltip = ({
useLayoutEffect(() => {
mutate();
}, [content, mutate]);
}, [mutate, content]);
if (!tooltipDom) {
return null;
}
return createPortal(
<div className={styles.tooltip}>
<TipBubble
ref={tooltipRef}
className={className}
style={{ ...(!layoutPosition && { opacity: 0 }), ...layoutPosition }}
position={position}
horizontalAlignment={positionState.horizontalAlign}
>
<div className={styles.content}>{content}</div>
</TipBubble>
</div>,
tooltipDom
return (
<>
<div ref={anchorRef} className={anchorClassName}>
{children}
</div>
{tooltipDom &&
content &&
createPortal(
<div className={styles.tooltip}>
<TipBubble
ref={tooltipRef}
className={className}
style={{ ...(!layoutPosition && { opacity: 0 }), ...layoutPosition }}
position={position}
horizontalAlignment={positionState.horizontalAlign}
>
<div className={styles.content}>{content}</div>
</TipBubble>
</div>,
tooltipDom
)}
</>
);
};

Some files were not shown because too many files have changed in this diff Show more