Merge branch 'master' into gao-fix-sie-forget-pwd-enabled
1
.github/CODEOWNERS
vendored
|
@ -1,2 +1,3 @@
|
|||
/packages/schemas/tables @simeng-li @wangsijie
|
||||
/packages/core/src/routes/session @simeng-li @wangsijie
|
||||
/.changeset @gao-sun
|
||||
|
|
2
.github/workflows/integration-test.yml
vendored
|
@ -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/
|
||||
|
|
12
.github/workflows/main.yml
vendored
|
@ -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
|
||||
|
|
1
.github/workflows/release.yml
vendored
|
@ -90,6 +90,7 @@ jobs:
|
|||
with:
|
||||
# Set Git operations with the bot PAT since we have tag protection rule
|
||||
token: ${{ secrets.BOT_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node and pnpm
|
||||
uses: silverhand-io/actions-node-pnpm-run-steps@v2
|
||||
|
|
1
.npmrc
|
@ -3,3 +3,4 @@ public-hoist-pattern[]=@parcel/*
|
|||
public-hoist-pattern[]=postcss
|
||||
public-hoist-pattern[]=process
|
||||
public-hoist-pattern[]=*eslint*
|
||||
public-hoist-pattern[]=buffer
|
||||
|
|
|
@ -68,7 +68,7 @@ npm init @logto
|
|||
## Language support
|
||||
|
||||
```ts
|
||||
const languages = ['English', 'Français', 'Português', '简体中文', 'Türkçe', '한국어'];
|
||||
const languages = ['Deutsch', 'English', 'Français', 'Português', '简体中文', 'Türkçe', '한국어'];
|
||||
```
|
||||
|
||||
## Bug report, feature request, feedback
|
||||
|
|
44
docker-compose.uffizzi.yml
Normal file
|
@ -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
|
||||
|
|
@ -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": [
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
require('../lib/index.js');
|
2
packages/cli/bin/logto.js
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env node
|
||||
import '../lib/index.js';
|
11
packages/cli/jest.config.js
Normal 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;
|
|
@ -1,7 +0,0 @@
|
|||
import { merge, Config } from '@silverhand/jest-config';
|
||||
|
||||
const config: Config.InitialOptions = merge({
|
||||
roots: ['./src'],
|
||||
});
|
||||
|
||||
export default config;
|
|
@ -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",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { log } from '../../utilities';
|
||||
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils';
|
||||
import { log } from '../../utilities.js';
|
||||
import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils.js';
|
||||
|
||||
const add: CommandModule<
|
||||
{ path?: string },
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import add from './add';
|
||||
import list from './list';
|
||||
import remove from './remove';
|
||||
import add from './add.js';
|
||||
import list from './list.js';
|
||||
import remove from './remove.js';
|
||||
|
||||
const connector: CommandModule = {
|
||||
command: ['connector', 'c', 'connectors'],
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { getConnectorPackagesFrom, isOfficialConnector } from './utils';
|
||||
import { getConnectorPackagesFrom, isOfficialConnector } from './utils.js';
|
||||
|
||||
const logConnectorNames = (type: string, names: string[]) => {
|
||||
if (names.length === 0) {
|
||||
|
|
|
@ -2,8 +2,8 @@ import chalk from 'chalk';
|
|||
import fsExtra from 'fs-extra';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { log } from '../../utilities';
|
||||
import { getConnectorPackagesFrom } from './utils';
|
||||
import { log } from '../../utilities.js';
|
||||
import { getConnectorPackagesFrom } from './utils.js';
|
||||
|
||||
const remove: CommandModule<{ path?: string }, { path?: string; packages?: string[] }> = {
|
||||
command: ['remove [packages...]', 'rm', 'delete'],
|
||||
|
|
|
@ -6,15 +6,15 @@ import { promisify } from 'util';
|
|||
|
||||
import { assert, conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { ensureDir, remove } from 'fs-extra';
|
||||
import fsExtra from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import pRetry from 'p-retry';
|
||||
import tar from 'tar';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { connectorDirectory } from '../../constants';
|
||||
import { getConnectorPackagesFromDirectory, isTty, log, oraPromise } from '../../utilities';
|
||||
import { defaultPath } from '../install/utils';
|
||||
import { connectorDirectory } from '../../constants.js';
|
||||
import { getConnectorPackagesFromDirectory, isTty, log, oraPromise } from '../../utilities.js';
|
||||
import { defaultPath } from '../install/utils.js';
|
||||
|
||||
const coreDirectory = 'packages/core';
|
||||
const execPromise = promisify(exec);
|
||||
|
@ -136,8 +136,8 @@ export const addConnectors = async (instancePath: string, packageNames: string[]
|
|||
const tarPath = path.join(cwd, escapedFilename);
|
||||
const packageDirectory = path.join(cwd, name.replace(/\//g, '-'));
|
||||
|
||||
await remove(packageDirectory);
|
||||
await ensureDir(packageDirectory);
|
||||
await fsExtra.remove(packageDirectory);
|
||||
await fsExtra.ensureDir(packageDirectory);
|
||||
await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 });
|
||||
await unlink(tarPath);
|
||||
|
||||
|
|
|
@ -1,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 () => {
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
55
packages/cli/src/commands/database/alteration/utils.ts
Normal 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 }));
|
||||
};
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -4,10 +4,10 @@ import { deduplicate, noop } from '@silverhand/essentials';
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { createPoolFromConfig } from '../../database';
|
||||
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config';
|
||||
import { log } from '../../utilities';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities';
|
||||
import { createPoolFromConfig } from '../../database.js';
|
||||
import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config.js';
|
||||
import { log } from '../../utilities.js';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from './utilities.js';
|
||||
|
||||
const validKeysDisplay = chalk.green(logtoConfigKeys.join(', '));
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import alteration from './alteration';
|
||||
import config from './config';
|
||||
import seed from './seed';
|
||||
import alteration from './alteration/index.js';
|
||||
import config from './config.js';
|
||||
import seed from './seed/index.js';
|
||||
|
||||
const database: CommandModule = {
|
||||
command: ['database', 'db'],
|
||||
|
|
|
@ -10,16 +10,16 @@ import { raw } from 'slonik-sql-tag-raw';
|
|||
import type { CommandModule } from 'yargs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database';
|
||||
import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database.js';
|
||||
import {
|
||||
getRowsByKeys,
|
||||
doesConfigsTableExist,
|
||||
updateDatabaseTimestamp,
|
||||
updateValueByKey,
|
||||
} from '../../../queries/logto-config';
|
||||
import { getPathInModule, log, oraPromise } from '../../../utilities';
|
||||
import { getLatestAlterationTimestamp } from '../alteration';
|
||||
import { oidcConfigReaders } from './oidc-config';
|
||||
} from '../../../queries/logto-config.js';
|
||||
import { getPathInModule, log, oraPromise } from '../../../utilities.js';
|
||||
import { getLatestAlterationTimestamp } from '../alteration/index.js';
|
||||
import { oidcConfigReaders } from './oidc-config.js';
|
||||
|
||||
const createTables = async (connection: DatabaseTransactionConnection) => {
|
||||
const tableDirectory = getPathInModule('@logto/schemas', 'tables');
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { LogtoOidcConfigType } from '@logto/schemas';
|
|||
import { LogtoOidcConfigKey } from '@logto/schemas';
|
||||
import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
|
||||
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities';
|
||||
import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js';
|
||||
|
||||
const isBase64FormatPrivateKey = (key: string) => !key.includes('-');
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import chalk from 'chalk';
|
||||
import type { CommandModule } from 'yargs';
|
||||
|
||||
import { getDatabaseUrlFromConfig } from '../../database';
|
||||
import { log } from '../../utilities';
|
||||
import { addOfficialConnectors } from '../connector/utils';
|
||||
import { getDatabaseUrlFromConfig } from '../../database.js';
|
||||
import { log } from '../../utilities.js';
|
||||
import { addOfficialConnectors } from '../connector/utils.js';
|
||||
import {
|
||||
validateNodeVersion,
|
||||
inquireInstancePath,
|
||||
|
@ -15,7 +15,7 @@ import {
|
|||
decompress,
|
||||
inquireOfficialConnectors,
|
||||
isUrl,
|
||||
} from './utils';
|
||||
} from './utils.js';
|
||||
|
||||
export type InstallArgs = {
|
||||
path?: string;
|
||||
|
|
|
@ -6,12 +6,12 @@ import path from 'path';
|
|||
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { remove, writeFile } from 'fs-extra';
|
||||
import fsExtra from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import * as semver from 'semver';
|
||||
import tar from 'tar';
|
||||
|
||||
import { createPoolAndDatabaseIfNeeded } from '../../database';
|
||||
import { createPoolAndDatabaseIfNeeded } from '../../database.js';
|
||||
import {
|
||||
cliConfig,
|
||||
ConfigKey,
|
||||
|
@ -20,8 +20,8 @@ import {
|
|||
log,
|
||||
oraPromise,
|
||||
safeExecSync,
|
||||
} from '../../utilities';
|
||||
import { seedByPool } from '../database/seed';
|
||||
} from '../../utilities.js';
|
||||
import { seedByPool } from '../database/seed/index.js';
|
||||
|
||||
export const defaultPath = path.join(os.homedir(), 'logto');
|
||||
const pgRequired = new semver.SemVer('14.0.0');
|
||||
|
@ -140,7 +140,7 @@ export const seedDatabase = async (instancePath: string) => {
|
|||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
await oraPromise(remove(instancePath), {
|
||||
await oraPromise(fsExtra.remove(instancePath), {
|
||||
text: 'Clean up',
|
||||
prefixText: chalk.blue('[info]'),
|
||||
});
|
||||
|
@ -156,7 +156,7 @@ export const seedDatabase = async (instancePath: string) => {
|
|||
|
||||
export const createEnv = async (instancePath: string, databaseUrl: string) => {
|
||||
const dotEnvPath = path.resolve(instancePath, '.env');
|
||||
await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, {
|
||||
await fsExtra.writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`);
|
||||
|
|
|
@ -6,7 +6,7 @@ import { createPool, parseDsn, sql, stringifyDsn } from 'slonik';
|
|||
import { createInterceptors } from 'slonik-interceptor-preset';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ConfigKey, getCliConfigWithPrompt, log } from './utilities';
|
||||
import { ConfigKey, getCliConfigWithPrompt, log } from './utilities.js';
|
||||
|
||||
export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto';
|
||||
|
||||
|
|
|
@ -3,11 +3,11 @@ import dotenv from 'dotenv';
|
|||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
import connector from './commands/connector';
|
||||
import database from './commands/database';
|
||||
import install from './commands/install';
|
||||
import { packageJson } from './package-json';
|
||||
import { cliConfig, ConfigKey } from './utilities';
|
||||
import connector from './commands/connector/index.js';
|
||||
import database from './commands/database/index.js';
|
||||
import install from './commands/install/index.js';
|
||||
import { packageJson } from './package-json.js';
|
||||
import { cliConfig, ConfigKey } from './utilities.js';
|
||||
|
||||
void yargs(hideBin(process.argv))
|
||||
.version(false)
|
||||
|
|
4
packages/cli/src/meta-url.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
// Have to define this in a separate file since Jest sticks with CJS
|
||||
// We need to mock this before running tests
|
||||
// https://github.com/facebook/jest/issues/12952
|
||||
export const metaUrl = import.meta.url;
|
|
@ -2,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({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copied from core
|
||||
|
||||
import type { QueryResult, QueryResultRow } from 'slonik';
|
||||
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.d';
|
||||
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.js';
|
||||
|
||||
export type QueryType = (
|
||||
sql: string,
|
||||
|
|
|
@ -1,18 +1,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();
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"allowJs": true
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
13
packages/console/.svgorc.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "preset-default",
|
||||
"params": {
|
||||
"overrides": {
|
||||
"cleanupIDs": false,
|
||||
"removeViewBox": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"homepage": "https://github.com/logto-io/logto#readme",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"precommit": "lint-staged",
|
||||
|
@ -18,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": {
|
||||
|
|
|
@ -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">
|
||||
|
|
3
packages/console/src/assets/images/caret-down.svg
Normal 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 |
3
packages/console/src/assets/images/caret-up.svg
Normal 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 |
|
@ -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 |
|
@ -1,4 +1,6 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
|
||||
<path d="M35.0894 14.0219C33.0498 13.086 30.8626 12.3966 28.5759 12.0017C28.5342 11.994 28.4926 12.0131 28.4712 12.0512C28.1899 12.5515 27.8783 13.2041 27.6601 13.7171C25.2005 13.3489 22.7536 13.3489 20.3444 13.7171C20.1262 13.1927 19.8033 12.5515 19.5208 12.0512C19.4993 12.0144 19.4577 11.9953 19.4161 12.0017C17.1305 12.3953 14.9434 13.0848 12.9026 14.0219C12.8849 14.0295 12.8698 14.0422 12.8597 14.0587C8.71119 20.2565 7.57473 26.302 8.13224 32.2725C8.13476 32.3017 8.15116 32.3297 8.17386 32.3474C10.9109 34.3575 13.5623 35.5778 16.1644 36.3866C16.206 36.3993 16.2501 36.3841 16.2766 36.3498C16.8922 35.5092 17.4409 34.6229 17.9113 33.6908C17.9391 33.6363 17.9126 33.5715 17.8558 33.5499C16.9855 33.2198 16.1568 32.8172 15.3596 32.3601C15.2966 32.3233 15.2915 32.2331 15.3495 32.19C15.5173 32.0643 15.6851 31.9335 15.8453 31.8014C15.8743 31.7773 15.9146 31.7722 15.9487 31.7874C21.1857 34.1785 26.8554 34.1785 32.0306 31.7874C32.0647 31.7709 32.1051 31.776 32.1353 31.8001C32.2955 31.9322 32.4633 32.0643 32.6323 32.19C32.6903 32.2331 32.6865 32.3233 32.6235 32.3601C31.8263 32.8261 30.9976 33.2198 30.126 33.5486C30.0693 33.5702 30.044 33.6363 30.0718 33.6908C30.5523 34.6216 31.101 35.5079 31.7052 36.3485C31.7304 36.3841 31.7758 36.3993 31.8175 36.3866C34.4322 35.5778 37.0835 34.3575 39.8206 32.3474C39.8446 32.3297 39.8597 32.303 39.8622 32.2738C40.5294 25.3712 38.7447 19.3753 35.131 14.06C35.1221 14.0422 35.107 14.0295 35.0894 14.0219ZM18.6934 28.6371C17.1167 28.6371 15.8175 27.1896 15.8175 25.4119C15.8175 23.6341 17.0915 22.1866 18.6934 22.1866C20.3078 22.1866 21.5944 23.6469 21.5692 25.4119C21.5692 27.1896 20.2952 28.6371 18.6934 28.6371ZM29.3263 28.6371C27.7497 28.6371 26.4505 27.1896 26.4505 25.4119C26.4505 23.6341 27.7244 22.1866 29.3263 22.1866C30.9408 22.1866 32.2274 23.6469 32.2022 25.4119C32.2022 27.1896 30.9408 28.6371 29.3263 28.6371Z" fill="#A5ABF0"/>
|
||||
<rect width="48" height="48" rx="12" fill="#191C1D"/>
|
||||
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
|
||||
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
|
||||
<path d="M35.0894 14.0219C33.0498 13.086 30.8626 12.3966 28.5759 12.0017C28.5342 11.994 28.4926 12.0131 28.4712 12.0512C28.1899 12.5515 27.8783 13.2041 27.6601 13.7171C25.2005 13.3489 22.7536 13.3489 20.3444 13.7171C20.1262 13.1927 19.8033 12.5515 19.5208 12.0512C19.4993 12.0144 19.4577 11.9953 19.4161 12.0017C17.1305 12.3953 14.9434 13.0848 12.9026 14.0219C12.8849 14.0295 12.8698 14.0422 12.8597 14.0587C8.71119 20.2565 7.57473 26.302 8.13224 32.2725C8.13476 32.3017 8.15116 32.3297 8.17386 32.3474C10.9109 34.3575 13.5623 35.5778 16.1644 36.3866C16.206 36.3993 16.2501 36.3841 16.2766 36.3498C16.8922 35.5092 17.4409 34.6229 17.9113 33.6908C17.9391 33.6363 17.9126 33.5715 17.8558 33.5499C16.9855 33.2198 16.1568 32.8172 15.3596 32.3601C15.2966 32.3233 15.2915 32.2331 15.3495 32.19C15.5173 32.0643 15.6851 31.9335 15.8453 31.8014C15.8743 31.7773 15.9146 31.7722 15.9487 31.7874C21.1857 34.1785 26.8554 34.1785 32.0306 31.7874C32.0647 31.7709 32.1051 31.776 32.1353 31.8001C32.2955 31.9322 32.4633 32.0643 32.6323 32.19C32.6903 32.2331 32.6865 32.3233 32.6235 32.3601C31.8263 32.8261 30.9976 33.2198 30.126 33.5486C30.0693 33.5702 30.044 33.6363 30.0718 33.6908C30.5523 34.6216 31.101 35.5079 31.7052 36.3485C31.7304 36.3841 31.7758 36.3993 31.8175 36.3866C34.4322 35.5778 37.0835 34.3575 39.8206 32.3474C39.8446 32.3297 39.8597 32.303 39.8622 32.2738C40.5294 25.3712 38.7447 19.3753 35.131 14.06C35.1221 14.0422 35.107 14.0295 35.0894 14.0219ZM18.6934 28.6371C17.1167 28.6371 15.8175 27.1896 15.8175 25.4119C15.8175 23.6341 17.0915 22.1866 18.6934 22.1866C20.3078 22.1866 21.5944 23.6469 21.5692 25.4119C21.5692 27.1896 20.2952 28.6371 18.6934 28.6371ZM29.3263 28.6371C27.7497 28.6371 26.4505 27.1896 26.4505 25.4119C26.4505 23.6341 27.7244 22.1866 29.3263 22.1866C30.9408 22.1866 32.2274 23.6469 32.2022 25.4119C32.2022 27.1896 30.9408 28.6371 29.3263 28.6371Z" fill="#5865F2"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
@ -1,4 +1,6 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
|
||||
<rect width="48" height="48" rx="12" fill="#191C1D"/>
|
||||
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
|
||||
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 40C32.8366 40 40 32.8366 40 24C40 15.1634 32.8366 8 24 8C15.1634 8 8 15.1634 8 24C8 32.8366 15.1634 40 24 40ZM30 16C30.4747 16 30.9301 16.0827 31.3526 16.2345L24.001 23.5861L16.6488 16.234C17.0709 16.0825 17.5258 16 18 16H30ZM24.7586 25.6569C24.5505 25.865 24.2735 25.9622 24.001 25.9485C23.7285 25.9622 23.4514 25.865 23.2433 25.6569L14.9723 17.3859C14.3665 18.087 14 19.0007 14 20V28C14 30.2091 15.7909 32 18 32H30C32.2091 32 34 30.2091 34 28V20C34 19.0012 33.6339 18.0879 33.0286 17.3869L24.7586 25.6569Z" fill="#8ECF8E"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 834 B After Width: | Height: | Size: 884 B |
|
@ -1,4 +1,6 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#3A3B59"/>
|
||||
<path d="M23.9999 8C20.2007 8.00016 16.5255 9.38638 13.6319 11.9106C10.7384 14.4349 8.81524 17.9324 8.20666 21.7774C7.59807 25.6225 8.34375 29.5641 10.3103 32.897C12.2768 36.2299 15.3358 38.7366 18.94 39.9686C19.74 40.1122 20.04 39.62 20.04 39.1894C20.04 38.7998 20.02 37.5079 20.02 36.134C16 36.8927 14.96 35.1293 14.64 34.2065C14.2849 33.3091 13.722 32.5138 13 31.8893C12.44 31.5817 11.64 30.823 12.98 30.8025C13.4916 30.8595 13.9824 31.042 14.4106 31.3347C14.8388 31.6274 15.1919 32.0216 15.44 32.484C15.6588 32.887 15.9531 33.2419 16.3059 33.5281C16.6587 33.8144 17.0631 34.0264 17.496 34.1522C17.9289 34.2779 18.3817 34.3148 18.8286 34.2608C19.2754 34.2068 19.7074 34.0629 20.0999 33.8373C20.1692 33.0034 20.5317 32.2236 21.12 31.6433C17.56 31.2332 13.84 29.8182 13.84 23.5435C13.8175 21.9131 14.4043 20.3357 15.48 19.1347C14.9908 17.7177 15.0481 16.1627 15.64 14.7875C15.64 14.7875 16.9799 14.3568 20.04 16.469C22.658 15.7307 25.4219 15.7307 28.0399 16.469C31.0998 14.3364 32.4399 14.7875 32.4399 14.7875C33.0319 16.1626 33.0891 17.7177 32.5999 19.1347C33.6788 20.3336 34.2661 21.9124 34.2399 23.5435C34.2399 29.8387 30.4999 31.2332 26.9399 31.6433C27.3217 32.0401 27.6158 32.5165 27.8022 33.0402C27.9885 33.5638 28.0628 34.1225 28.0199 34.6782C28.0199 36.8723 27.9999 38.6357 27.9999 39.1894C27.9999 39.6201 28.2999 40.1327 29.0999 39.9687C32.6977 38.7266 35.748 36.214 37.7061 32.8794C39.6642 29.5448 40.4028 25.6052 39.79 21.7639C39.1772 17.9226 37.2529 14.4296 34.3606 11.9084C31.4683 9.38726 27.7962 8.00203 23.9999 8Z" fill="#B3A6DA"/>
|
||||
<rect width="48" height="48" rx="12" fill="#191C1D"/>
|
||||
<rect width="48" height="48" rx="12" fill="#C4C7C7" fill-opacity="0.02"/>
|
||||
<rect width="48" height="48" rx="12" fill="#CABEFF" fill-opacity="0.14"/>
|
||||
<path d="M23.9999 8C20.2007 8.00016 16.5255 9.38638 13.6319 11.9106C10.7384 14.4349 8.81524 17.9324 8.20666 21.7774C7.59807 25.6225 8.34375 29.5641 10.3103 32.897C12.2768 36.2299 15.3358 38.7366 18.94 39.9686C19.74 40.1122 20.04 39.62 20.04 39.1894C20.04 38.7998 20.02 37.5079 20.02 36.134C16 36.8927 14.96 35.1293 14.64 34.2065C14.2849 33.3091 13.722 32.5138 13 31.8893C12.44 31.5817 11.64 30.823 12.98 30.8025C13.4916 30.8595 13.9824 31.042 14.4106 31.3347C14.8388 31.6274 15.1919 32.0216 15.44 32.484C15.6588 32.887 15.9531 33.2419 16.3059 33.5281C16.6587 33.8144 17.0631 34.0264 17.496 34.1522C17.9289 34.2779 18.3817 34.3148 18.8286 34.2608C19.2754 34.2068 19.7074 34.0629 20.0999 33.8373C20.1692 33.0034 20.5317 32.2236 21.12 31.6433C17.56 31.2332 13.84 29.8182 13.84 23.5435C13.8175 21.9131 14.4043 20.3357 15.48 19.1347C14.9908 17.7177 15.0481 16.1627 15.64 14.7875C15.64 14.7875 16.9799 14.3568 20.04 16.469C22.658 15.7307 25.4219 15.7307 28.0399 16.469C31.0998 14.3364 32.4399 14.7875 32.4399 14.7875C33.0319 16.1626 33.0891 17.7177 32.5999 19.1347C33.6788 20.3336 34.2661 21.9124 34.2399 23.5435C34.2399 29.8387 30.4999 31.2332 26.9399 31.6433C27.3217 32.0401 27.6158 32.5165 27.8022 33.0402C27.9885 33.5638 28.0628 34.1225 28.0199 34.6782C28.0199 36.8723 27.9999 38.6357 27.9999 39.1894C27.9999 39.6201 28.2999 40.1327 29.0999 39.9687C32.6977 38.7266 35.748 36.214 37.7061 32.8794C39.6642 29.5448 40.4028 25.6052 39.79 21.7639C39.1772 17.9226 37.2529 14.4296 34.3606 11.9084C31.4683 9.38726 27.7962 8.00203 23.9999 8Z" fill="white"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
@ -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 |
|
@ -1,3 +1,10 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.content {
|
||||
padding: _.unit(1);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.dropdownTitle {
|
||||
padding: _.unit(3);
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ const ActionMenu = ({
|
|||
/>
|
||||
<Dropdown
|
||||
title={title}
|
||||
titleClassName={styles.dropdownTitle}
|
||||
anchorRef={anchorReference}
|
||||
isOpen={isOpen}
|
||||
className={classNames(styles.content, dropdownClassName)}
|
||||
|
|
|
@ -24,13 +24,6 @@
|
|||
margin: 0 _.unit(3);
|
||||
}
|
||||
|
||||
.action {
|
||||
a {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: var(--color-surface-variant);
|
||||
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
flex-shrink: 0;
|
||||
width: 248px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
> div + div {
|
||||
margin-top: _.unit(6);
|
||||
|
|
|
@ -42,11 +42,12 @@
|
|||
}
|
||||
|
||||
.dropdown {
|
||||
padding: _.unit(2);
|
||||
padding: _.unit(1);
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
min-width: 170px;
|
||||
padding: _.unit(2.5) _.unit(2);
|
||||
|
||||
&.loading {
|
||||
opacity: 60%;
|
||||
|
|
|
@ -26,11 +26,7 @@ const UserInfo = () => {
|
|||
(async () => {
|
||||
if (isAuthenticated) {
|
||||
const userInfo = await getIdTokenClaims();
|
||||
// TODO: revert after SDK updated
|
||||
setUser({
|
||||
picture: undefined,
|
||||
...(userInfo ?? { sub: '', username: 'N/A' }),
|
||||
}); // Provide a fallback to avoid infinite loading state
|
||||
setUser(userInfo ?? { sub: '', username: 'N/A' }); // Provide a fallback to avoid infinite loading state
|
||||
}
|
||||
})();
|
||||
}, [isAuthenticated, getIdTokenClaims]);
|
||||
|
@ -55,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>
|
||||
|
|
|
@ -15,13 +15,12 @@
|
|||
.content {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
margin-bottom: _.unit(6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding-right: _.unit(6);
|
||||
padding: 0 _.unit(2);
|
||||
overflow-y: scroll;
|
||||
|
||||
> * {
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: _.unit(3);
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
background-color: var(--color-layer-1);
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
.title {
|
||||
color: var(--color-text-secondary);
|
||||
|
@ -21,14 +31,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
.tableLayout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
.tableContainer {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: _.unit(4);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.eventName {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { LogDto } from '@logto/schemas';
|
||||
import { LogResult } from '@logto/schemas';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
@ -26,6 +26,8 @@ type Props = {
|
|||
userId?: string;
|
||||
};
|
||||
|
||||
const defaultTableColumn = 4;
|
||||
|
||||
const AuditLogTable = ({ userId }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { pathname } = useLocation();
|
||||
|
@ -49,6 +51,7 @@ const AuditLogTable = ({ userId }: Props) => {
|
|||
const navigate = useNavigate();
|
||||
const [logs, totalCount] = data ?? [];
|
||||
const showUserColumn = !userId;
|
||||
const tableColumnCount = showUserColumn ? defaultTableColumn : defaultTableColumn - 1;
|
||||
|
||||
const updateQuery = (key: string, value: string) => {
|
||||
const queries: Record<string, string> = {};
|
||||
|
@ -65,91 +68,91 @@ const AuditLogTable = ({ userId }: Props) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.title}>{t('logs.filter_by')}</div>
|
||||
<div className={styles.eventSelector}>
|
||||
<EventSelector
|
||||
value={event ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('event', value ?? '');
|
||||
}}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.tableLayout}>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.title}>{t('logs.filter_by')}</div>
|
||||
<div className={styles.eventSelector}>
|
||||
<EventSelector
|
||||
value={event ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('event', value ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.applicationSelector}>
|
||||
<ApplicationSelector
|
||||
value={applicationId ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('applicationId', value ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.applicationSelector}>
|
||||
<ApplicationSelector
|
||||
value={applicationId ?? undefined}
|
||||
onChange={(value) => {
|
||||
updateQuery('applicationId', value ?? '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table className={classNames(logs?.length === 0 && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.eventName} />
|
||||
{showUserColumn && <col />}
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event')}</th>
|
||||
{showUserColumn && <th>{t('logs.user')}</th>}
|
||||
<th>{t('logs.application')}</th>
|
||||
<th>{t('logs.time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={4}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={4} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={4} />}
|
||||
{logs?.map(({ type, payload, createdAt, id }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`${pathname}/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
|
||||
</td>
|
||||
{showUserColumn && (
|
||||
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
|
||||
)}
|
||||
<td>
|
||||
{payload.applicationId ? (
|
||||
<ApplicationName applicationId={payload.applicationId} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||
<div className={classNames(tableStyles.scrollable, styles.tableContainer)}>
|
||||
<table className={conditional(logs?.length === 0 && tableStyles.empty)}>
|
||||
<colgroup>
|
||||
<col className={styles.eventName} />
|
||||
{showUserColumn && <col />}
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event')}</th>
|
||||
{showUserColumn && <th>{t('logs.user')}</th>}
|
||||
<th>{t('logs.application')}</th>
|
||||
<th>{t('logs.time')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={tableColumnCount}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={tableColumnCount} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={tableColumnCount} />}
|
||||
{logs?.map(({ type, payload, createdAt, id }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`${pathname}/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
|
||||
</td>
|
||||
{showUserColumn && (
|
||||
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
|
||||
)}
|
||||
<td>
|
||||
{payload.applicationId ? (
|
||||
<ApplicationName applicationId={payload.applicationId} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagination}>
|
||||
{!!totalCount && (
|
||||
<Pagination
|
||||
pageCount={Math.ceil(totalCount / pageSize)}
|
||||
pageIndex={pageIndex}
|
||||
onChange={(page) => {
|
||||
updateQuery('page', String(page));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Pagination
|
||||
pageIndex={pageIndex}
|
||||
totalCount={totalCount ?? 0}
|
||||
pageSize={pageSize}
|
||||
className={styles.pagination}
|
||||
onChange={(page) => {
|
||||
updateQuery('page', String(page));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
29
packages/console/src/components/DetailsForm/index.tsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
|
|
46
packages/console/src/components/FormCard/index.module.scss
Normal 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;
|
||||
}
|
39
packages/console/src/components/FormCard/index.tsx
Normal 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;
|
|
@ -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 {
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
min-width: dim.$modal-layout-width-small;
|
||||
padding: _.unit(6);
|
||||
margin: 0 _.unit(6);
|
||||
border: 1px solid var(--color-divider);
|
||||
box-shadow: var(--shadow-3);
|
||||
|
||||
.header {
|
||||
|
|
|
@ -5,14 +5,20 @@
|
|||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.firstFieldWithMultiInputs {
|
||||
padding-right: _.unit(9);
|
||||
}
|
||||
|
||||
.deletableInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> :first-child {
|
||||
@include _.form-text-field;
|
||||
margin-right: _.unit(2);
|
||||
flex-shrink: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.headlineWithMultiInputs {
|
||||
padding-right: _.unit(9);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import type { Props as FormFieldProps } from '@/components/FormField';
|
||||
import FormField from '@/components/FormField';
|
||||
import type { Props as MultiTextInputProps } from '@/components/MultiTextInput';
|
||||
|
||||
import MultiTextInput from '../MultiTextInput';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = MultiTextInputProps &
|
||||
Pick<FormFieldProps, 'isRequired' | '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;
|
|
@ -1,9 +1,22 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.positionInfo {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
margin: 0;
|
||||
height: 28px;
|
||||
padding-inline-start: _.unit(4);
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactPaginate from 'react-paginate';
|
||||
|
||||
import Button from '../Button';
|
||||
|
@ -9,34 +10,51 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Props = {
|
||||
pageIndex: number;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
className?: string;
|
||||
onChange?: (pageIndex: number) => void;
|
||||
};
|
||||
|
||||
const Pagination = ({ pageIndex, pageCount, onChange }: Props) => {
|
||||
const Pagination = ({ pageIndex, totalCount, pageSize, className, onChange }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const pageCount = Math.ceil(totalCount / pageSize);
|
||||
|
||||
if (pageCount <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const min = (pageIndex - 1) * pageSize + 1;
|
||||
const max = Math.min(pageIndex * pageSize, totalCount);
|
||||
|
||||
return (
|
||||
<ReactPaginate
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
forcePage={pageIndex - 1}
|
||||
pageLabelBuilder={(page: number) => (
|
||||
<Button
|
||||
type={page === pageIndex ? 'outline' : 'default'}
|
||||
className={classNames(styles.button, page === pageIndex && styles.active)}
|
||||
size="small"
|
||||
title={<DangerousRaw>{page}</DangerousRaw>}
|
||||
/>
|
||||
)}
|
||||
previousLabel={<Button className={styles.button} size="small" icon={<Previous />} />}
|
||||
nextLabel={<Button className={styles.button} size="small" icon={<Next />} />}
|
||||
breakLabel={
|
||||
<Button className={styles.button} size="small" title={<DangerousRaw>...</DangerousRaw>} />
|
||||
}
|
||||
disabledClassName={styles.disabled}
|
||||
onPageChange={({ selected }) => {
|
||||
onChange?.(selected + 1);
|
||||
}}
|
||||
/>
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.positionInfo}>
|
||||
{t('general.page_info', { min, max, total: totalCount })}
|
||||
</div>
|
||||
<ReactPaginate
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
forcePage={pageIndex - 1}
|
||||
pageLabelBuilder={(page: number) => (
|
||||
<Button
|
||||
type={page === pageIndex ? 'outline' : 'default'}
|
||||
className={classNames(styles.button, page === pageIndex && styles.active)}
|
||||
size="small"
|
||||
title={<DangerousRaw>{page}</DangerousRaw>}
|
||||
/>
|
||||
)}
|
||||
previousLabel={<Button className={styles.button} size="small" icon={<Previous />} />}
|
||||
nextLabel={<Button className={styles.button} size="small" icon={<Next />} />}
|
||||
breakLabel={
|
||||
<Button className={styles.button} size="small" title={<DangerousRaw>...</DangerousRaw>} />
|
||||
}
|
||||
disabledClassName={styles.disabled}
|
||||
onPageChange={({ selected }) => {
|
||||
onChange?.(selected + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
41
packages/console/src/components/TextLink/index.tsx
Normal 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;
|
|
@ -10,6 +10,14 @@
|
|||
font: var(--font-body-medium);
|
||||
max-width: 300px;
|
||||
|
||||
a {
|
||||
color: #cabeff;
|
||||
|
||||
&:active {
|
||||
color: #cabeff;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
104
packages/console/src/components/Tip/ToggleTip/index.tsx
Normal 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;
|
|
@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|