diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7fceb6eef..d9132021c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,3 @@ /packages/schemas/tables @simeng-li @wangsijie +/packages/core/src/routes/session @simeng-li @wangsijie /.changeset @gao-sun diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 787e41aab..578674a77 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -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/ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index af5765f0d..abca19613 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a83813ab1..74f266fdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.npmrc b/.npmrc index ad46c0d5b..573e44e09 100644 --- a/.npmrc +++ b/.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 diff --git a/README.md b/README.md index 9d8bfff9a..04e21ca9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.uffizzi.yml b/docker-compose.uffizzi.yml new file mode 100644 index 000000000..11d8cf2e7 --- /dev/null +++ b/docker-compose.uffizzi.yml @@ -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 + diff --git a/package.json b/package.json index de3801af8..c1f418579 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/packages/cli/bin/logto b/packages/cli/bin/logto deleted file mode 100755 index 527eddf67..000000000 --- a/packages/cli/bin/logto +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -require('../lib/index.js'); diff --git a/packages/cli/bin/logto.js b/packages/cli/bin/logto.js new file mode 100755 index 000000000..5d5cb2298 --- /dev/null +++ b/packages/cli/bin/logto.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../lib/index.js'; diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js new file mode 100644 index 000000000..3fd28f230 --- /dev/null +++ b/packages/cli/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('jest').Config} */ +const config = { + coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'], + coverageReporters: ['text-summary', 'lcov'], + roots: ['./lib'], + moduleNameMapper: { + '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + }, +}; + +export default config; diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts deleted file mode 100644 index f3ba355b8..000000000 --- a/packages/cli/jest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { merge, Config } from '@silverhand/jest-config'; - -const config: Config.InitialOptions = merge({ - roots: ['./src'], -}); - -export default config; diff --git a/packages/cli/package.json b/packages/cli/package.json index 935598ded..b5494624c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,12 +5,13 @@ "author": "Silverhand Inc. ", "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", diff --git a/packages/cli/src/commands/connector/add.ts b/packages/cli/src/commands/connector/add.ts index a2575e79f..9377efd68 100644 --- a/packages/cli/src/commands/connector/add.ts +++ b/packages/cli/src/commands/connector/add.ts @@ -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 }, diff --git a/packages/cli/src/commands/connector/index.ts b/packages/cli/src/commands/connector/index.ts index 5a282715a..80777e3cf 100644 --- a/packages/cli/src/commands/connector/index.ts +++ b/packages/cli/src/commands/connector/index.ts @@ -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'], diff --git a/packages/cli/src/commands/connector/list.ts b/packages/cli/src/commands/connector/list.ts index 91d5c0765..3d5ea9980 100644 --- a/packages/cli/src/commands/connector/list.ts +++ b/packages/cli/src/commands/connector/list.ts @@ -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) { diff --git a/packages/cli/src/commands/connector/remove.ts b/packages/cli/src/commands/connector/remove.ts index 8e932fbe8..fa54265eb 100644 --- a/packages/cli/src/commands/connector/remove.ts +++ b/packages/cli/src/commands/connector/remove.ts @@ -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'], diff --git a/packages/cli/src/commands/connector/utils.ts b/packages/cli/src/commands/connector/utils.ts index a43290672..249ec7706 100644 --- a/packages/cli/src/commands/connector/utils.ts +++ b/packages/cli/src/commands/connector/utils.ts @@ -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); diff --git a/packages/cli/src/commands/database/alteration/index.test.ts b/packages/cli/src/commands/database/alteration/index.test.ts index 490f4d15e..6033c4c23 100644 --- a/packages/cli/src/commands/database/alteration/index.test.ts +++ b/packages/cli/src/commands/database/alteration/index.test.ts @@ -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 = 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 () => { diff --git a/packages/cli/src/commands/database/alteration/index.ts b/packages/cli/src/commands/database/alteration/index.ts index 11896258e..17a93a3b1 100644 --- a/packages/cli/src/commands/database/alteration/index.ts +++ b/packages/cli/src/commands/database/alteration/index.ts @@ -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 => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -37,44 +22,6 @@ const importAlterationScript = async (filePath: string): Promise => { - 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]; diff --git a/packages/cli/src/commands/database/alteration/meta-url.ts b/packages/cli/src/commands/database/alteration/meta-url.ts new file mode 100644 index 000000000..740bd697b --- /dev/null +++ b/packages/cli/src/commands/database/alteration/meta-url.ts @@ -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; diff --git a/packages/cli/src/commands/database/alteration/utils.ts b/packages/cli/src/commands/database/alteration/utils.ts new file mode 100644 index 000000000..6037b4fc2 --- /dev/null +++ b/packages/cli/src/commands/database/alteration/utils.ts @@ -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 => { + 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 })); +}; diff --git a/packages/cli/src/commands/database/alteration/version.ts b/packages/cli/src/commands/database/alteration/version.ts index afbae1ac1..5e0c01fa9 100644 --- a/packages/cli/src/commands/database/alteration/version.ts +++ b/packages/cli/src/commands/database/alteration/version.ts @@ -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) ); diff --git a/packages/cli/src/commands/database/config.ts b/packages/cli/src/commands/database/config.ts index cb61e1fa5..3e723151c 100644 --- a/packages/cli/src/commands/database/config.ts +++ b/packages/cli/src/commands/database/config.ts @@ -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(', ')); diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts index 4a81096f5..08a4ef5fc 100644 --- a/packages/cli/src/commands/database/index.ts +++ b/packages/cli/src/commands/database/index.ts @@ -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'], diff --git a/packages/cli/src/commands/database/seed/index.ts b/packages/cli/src/commands/database/seed/index.ts index b57162c2b..75a70a1f7 100644 --- a/packages/cli/src/commands/database/seed/index.ts +++ b/packages/cli/src/commands/database/seed/index.ts @@ -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'); diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts index a65b19c29..f7cd46650 100644 --- a/packages/cli/src/commands/database/seed/oidc-config.ts +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -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('-'); diff --git a/packages/cli/src/commands/install/index.ts b/packages/cli/src/commands/install/index.ts index 4761c45b9..251539342 100644 --- a/packages/cli/src/commands/install/index.ts +++ b/packages/cli/src/commands/install/index.ts @@ -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; diff --git a/packages/cli/src/commands/install/utils.ts b/packages/cli/src/commands/install/utils.ts index 529d562f8..4dfdcab1b 100644 --- a/packages/cli/src/commands/install/utils.ts +++ b/packages/cli/src/commands/install/utils.ts @@ -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)}`); diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index b46be99c8..7eecce51a 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -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'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e0596c9e4..b651a7d1b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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) diff --git a/packages/cli/src/meta-url.ts b/packages/cli/src/meta-url.ts new file mode 100644 index 000000000..740bd697b --- /dev/null +++ b/packages/cli/src/meta-url.ts @@ -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; diff --git a/packages/cli/src/queries/logto-config.test.ts b/packages/cli/src/queries/logto-config.test.ts index bdfcfa857..c19c8d283 100644 --- a/packages/cli/src/queries/logto-config.test.ts +++ b/packages/cli/src/queries/logto-config.test.ts @@ -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 = jest.fn(); const pool = createMockPool({ diff --git a/packages/cli/src/test-utilities.ts b/packages/cli/src/test-utilities.ts index 04057500c..ce1310ed3 100644 --- a/packages/cli/src/test-utilities.ts +++ b/packages/cli/src/test-utilities.ts @@ -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, diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts index c1d90cd6c..50d35f400 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -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 ( promise: PromiseLike, - options?: ora.Options, + options?: Options, exitOnError = false ) => { const spinner = ora(options).start(); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index ef675dbdc..50fa1e84b 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -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"] } diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json index c68416b04..55de18c33 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -1,6 +1,8 @@ { "extends": "./tsconfig", "compilerOptions": { + "isolatedModules": false, "allowJs": true - } + }, + "include": ["src"] } diff --git a/packages/console/.svgorc.json b/packages/console/.svgorc.json new file mode 100644 index 000000000..3fe9143d0 --- /dev/null +++ b/packages/console/.svgorc.json @@ -0,0 +1,13 @@ +{ + "plugins": [ + { + "name": "preset-default", + "params": { + "overrides": { + "cleanupIDs": false, + "removeViewBox": false + } + } + } + ] +} diff --git a/packages/console/package.json b/packages/console/package.json index 6912e76be..2f132837a 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -5,6 +5,7 @@ "author": "Silverhand Inc. ", "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": { diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index 5af79837c..7c1a17fa9 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -55,7 +55,6 @@ const Main = () => { } /> } /> - } /> diff --git a/packages/console/src/assets/images/caret-down.svg b/packages/console/src/assets/images/caret-down.svg new file mode 100644 index 000000000..a485a9313 --- /dev/null +++ b/packages/console/src/assets/images/caret-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/assets/images/caret-up.svg b/packages/console/src/assets/images/caret-up.svg new file mode 100644 index 000000000..9f31eabf2 --- /dev/null +++ b/packages/console/src/assets/images/caret-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/assets/images/circle-plus.svg b/packages/console/src/assets/images/circle-plus.svg index 1d2315bc1..686f04f26 100644 --- a/packages/console/src/assets/images/circle-plus.svg +++ b/packages/console/src/assets/images/circle-plus.svg @@ -1,5 +1,3 @@ - + diff --git a/packages/console/src/assets/images/discord-dark.svg b/packages/console/src/assets/images/discord-dark.svg index 915bb0e03..037c4f46f 100644 --- a/packages/console/src/assets/images/discord-dark.svg +++ b/packages/console/src/assets/images/discord-dark.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/packages/console/src/assets/images/email-dark.svg b/packages/console/src/assets/images/email-dark.svg index 9d3de66c1..f5d082411 100644 --- a/packages/console/src/assets/images/email-dark.svg +++ b/packages/console/src/assets/images/email-dark.svg @@ -1,4 +1,6 @@ - + + + diff --git a/packages/console/src/assets/images/github-dark.svg b/packages/console/src/assets/images/github-dark.svg index 3b74303a9..c44efff27 100644 --- a/packages/console/src/assets/images/github-dark.svg +++ b/packages/console/src/assets/images/github-dark.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/packages/console/src/assets/images/minus.svg b/packages/console/src/assets/images/minus.svg index 6176fc2b3..8e8ac8c11 100644 --- a/packages/console/src/assets/images/minus.svg +++ b/packages/console/src/assets/images/minus.svg @@ -1,5 +1,3 @@ - - + + diff --git a/packages/console/src/components/ActionMenu/index.module.scss b/packages/console/src/components/ActionMenu/index.module.scss index f00ee8290..28f835619 100644 --- a/packages/console/src/components/ActionMenu/index.module.scss +++ b/packages/console/src/components/ActionMenu/index.module.scss @@ -1,3 +1,10 @@ +@use '@/scss/underscore' as _; + .content { + padding: _.unit(1); min-width: 200px; } + +.dropdownTitle { + padding: _.unit(3); +} diff --git a/packages/console/src/components/ActionMenu/index.tsx b/packages/console/src/components/ActionMenu/index.tsx index 81362fdec..c8937af61 100644 --- a/packages/console/src/components/ActionMenu/index.tsx +++ b/packages/console/src/components/ActionMenu/index.tsx @@ -42,6 +42,7 @@ const ActionMenu = ({ /> { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + return (
{children}
- {action && href && ( -
- -
- )} + {action && href && {t(action)}} {action && onClick && (
); }; diff --git a/packages/console/src/components/CardTitle/index.tsx b/packages/console/src/components/CardTitle/index.tsx index 30efca0ca..5e7c78065 100644 --- a/packages/console/src/components/CardTitle/index.tsx +++ b/packages/console/src/components/CardTitle/index.tsx @@ -10,16 +10,17 @@ type Props = { title: AdminConsoleKey | ReactElement; subtitle?: AdminConsoleKey | ReactElement; 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 ( -
+
{typeof title === 'string' ? t(title) : title}
{subtitle && (
diff --git a/packages/console/src/components/Checkbox/index.tsx b/packages/console/src/components/Checkbox/index.tsx index 0c341c911..e7ca28f81 100644 --- a/packages/console/src/components/Checkbox/index.tsx +++ b/packages/console/src/components/Checkbox/index.tsx @@ -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(null); - return (
{disabled && disabledTooltip && ( - <> -
- - + )} {label && } diff --git a/packages/console/src/components/ColorPicker/index.module.scss b/packages/console/src/components/ColorPicker/index.module.scss index 708f16fa3..3bfd3568d 100644 --- a/packages/console/src/components/ColorPicker/index.module.scss +++ b/packages/console/src/components/ColorPicker/index.module.scss @@ -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); } } diff --git a/packages/console/src/components/ConfirmModal/index.tsx b/packages/console/src/components/ConfirmModal/index.tsx index 8c586bfe2..dd85c6e93 100644 --- a/packages/console/src/components/ConfirmModal/index.tsx +++ b/packages/console/src/components/ConfirmModal/index.tsx @@ -39,15 +39,17 @@ const ConfirmModal = ({ }: ConfirmModalProps) => { return ( - ); }; diff --git a/packages/console/src/components/LinkButton/index.tsx b/packages/console/src/components/LinkButton/index.tsx deleted file mode 100644 index cdbed5384..000000000 --- a/packages/console/src/components/LinkButton/index.tsx +++ /dev/null @@ -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; - icon?: ReactNode; - className?: string; -}; - -const LinkButton = ({ to, title, icon, className }: Props) => { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - - return ( - - {icon} - {typeof title === 'string' ? {t(title)} : title} - - ); -}; - -export default LinkButton; diff --git a/packages/console/src/components/Markdown/index.module.scss b/packages/console/src/components/Markdown/index.module.scss index 86e0e268a..62922ed89 100644 --- a/packages/console/src/components/Markdown/index.module.scss +++ b/packages/console/src/components/Markdown/index.module.scss @@ -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); } } diff --git a/packages/console/src/components/ModalLayout/index.module.scss b/packages/console/src/components/ModalLayout/index.module.scss index 76777ddbe..e1cf2cd39 100644 --- a/packages/console/src/components/ModalLayout/index.module.scss +++ b/packages/console/src/components/ModalLayout/index.module.scss @@ -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 { diff --git a/packages/console/src/components/MultiTextInput/index.module.scss b/packages/console/src/components/MultiTextInput/index.module.scss index d70a04d68..3110f2f5b 100644 --- a/packages/console/src/components/MultiTextInput/index.module.scss +++ b/packages/console/src/components/MultiTextInput/index.module.scss @@ -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); } } diff --git a/packages/console/src/components/MultiTextInput/index.tsx b/packages/console/src/components/MultiTextInput/index.tsx index 90ce7c422..dda2ec39b 100644 --- a/packages/console/src/components/MultiTextInput/index.tsx +++ b/packages/console/src/components/MultiTextInput/index.tsx @@ -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) => 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(); @@ -47,10 +59,15 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder }; return ( -
+
{fields.map((fieldValue, fieldIndex) => ( - // eslint-disable-next-line react/no-array-index-key -
+
1 && fieldIndex === 0 && styles.firstFieldWithMultiInputs + )} + >
} onClick={handleAdd} /> & { + formFieldClassName?: FormFieldProps['className']; + }; + +const MultiTextInputField = ({ + title, + isRequired, + tip, + formFieldClassName, + value, + ...rest +}: Props) => ( + 1 && styles.headlineWithMultiInputs)} + > + + +); + +export default MultiTextInputField; diff --git a/packages/console/src/components/Pagination/index.module.scss b/packages/console/src/components/Pagination/index.module.scss index f5d085b4f..98a1cb934 100644 --- a/packages/console/src/components/Pagination/index.module.scss +++ b/packages/console/src/components/Pagination/index.module.scss @@ -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; diff --git a/packages/console/src/components/Pagination/index.tsx b/packages/console/src/components/Pagination/index.tsx index 4a88ef826..07894c8fc 100644 --- a/packages/console/src/components/Pagination/index.tsx +++ b/packages/console/src/components/Pagination/index.tsx @@ -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 ( - ( -
); }; diff --git a/packages/console/src/components/RadioGroup/index.tsx b/packages/console/src/components/RadioGroup/index.tsx index a0061869b..cd5209316 100644 --- a/packages/console/src/components/RadioGroup/index.tsx +++ b/packages/console/src/components/RadioGroup/index.tsx @@ -26,6 +26,8 @@ const RadioGroup = ( return child; } + // FIXME: @Charles + // @ts-expect-error to be fixed return cloneElement(child, { name, isChecked: value === child.props.value, diff --git a/packages/console/src/components/Select/index.module.scss b/packages/console/src/components/Select/index.module.scss index cd31d4f5f..9a3d5f24a 100644 --- a/packages/console/src/components/Select/index.module.scss +++ b/packages/console/src/components/Select/index.module.scss @@ -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; +} diff --git a/packages/console/src/components/Select/index.tsx b/packages/console/src/components/Select/index.tsx index 78b2de26c..d81a9ec9a 100644 --- a/packages/console/src/components/Select/index.tsx +++ b/packages/console/src/components/Select/index.tsx @@ -80,7 +80,7 @@ const Select = ({ } }} > - {current?.title ?? placeholder} +
{current?.title ?? placeholder}
{isClearable && ( ({ { setIsOpen(false); diff --git a/packages/console/src/components/SubmitFormChangesActionBar/index.module.scss b/packages/console/src/components/SubmitFormChangesActionBar/index.module.scss new file mode 100644 index 000000000..cd88d406c --- /dev/null +++ b/packages/console/src/components/SubmitFormChangesActionBar/index.module.scss @@ -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; + } + } +} diff --git a/packages/console/src/components/SubmitFormChangesActionBar/index.tsx b/packages/console/src/components/SubmitFormChangesActionBar/index.tsx new file mode 100644 index 000000000..64265813d --- /dev/null +++ b/packages/console/src/components/SubmitFormChangesActionBar/index.tsx @@ -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; + onDiscard: () => void; +}; + +const SubmitFormChangesActionBar = ({ isOpen, isSubmitting, onSubmit, onDiscard }: Props) => ( +
+
+
+
+); + +export default SubmitFormChangesActionBar; diff --git a/packages/console/src/components/TabNav/TabNavItem.module.scss b/packages/console/src/components/TabNav/TabNavItem.module.scss index 36f053a0c..89b59946d 100644 --- a/packages/console/src/components/TabNav/TabNavItem.module.scss +++ b/packages/console/src/components/TabNav/TabNavItem.module.scss @@ -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; } } + diff --git a/packages/console/src/components/TabNav/TabNavItem.tsx b/packages/console/src/components/TabNav/TabNavItem.tsx index 77214ff78..72fb788d5 100644 --- a/packages/console/src/components/TabNav/TabNavItem.tsx +++ b/packages/console/src/components/TabNav/TabNavItem.tsx @@ -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>) + | (BaseProps & TabStyleProps & Partial>); + +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 ( -
- {href ? ( - {children} - ) : ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid - - {children} - +
+
+ {href ? ( + {children} + ) : ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + {children} + + )} +
+ {errorCount > 0 && ( +
{t('general.tab_errors', { count: errorCount })}
)}
); diff --git a/packages/console/src/components/TabNav/index.module.scss b/packages/console/src/components/TabNav/index.module.scss index 26c99e0a4..984980080 100644 --- a/packages/console/src/components/TabNav/index.module.scss +++ b/packages/console/src/components/TabNav/index.module.scss @@ -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); } diff --git a/packages/console/src/components/LinkButton/index.module.scss b/packages/console/src/components/TextLink/index.module.scss similarity index 58% rename from packages/console/src/components/LinkButton/index.module.scss rename to packages/console/src/components/TextLink/index.module.scss index b4f10f907..c187d172e 100644 --- a/packages/console/src/components/LinkButton/index.module.scss +++ b/packages/console/src/components/TextLink/index.module.scss @@ -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; } } diff --git a/packages/console/src/components/TextLink/index.tsx b/packages/console/src/components/TextLink/index.tsx new file mode 100644 index 000000000..a9d889b10 --- /dev/null +++ b/packages/console/src/components/TextLink/index.tsx @@ -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 & + Partial & { + icon?: ReactNode; + isTrailingIcon?: boolean; + }; + +const TextLink = ({ to, children, icon, isTrailingIcon = false, className, ...rest }: Props) => { + if (to) { + return ( + + {icon} + {/* eslint-disable-next-line react/jsx-no-useless-fragment */} + <>{children} + + ); + } + + return ( + + {icon} + {/* eslint-disable-next-line react/jsx-no-useless-fragment */} + <>{children} + + ); +}; + +export default TextLink; diff --git a/packages/console/src/components/TipBubble/index.module.scss b/packages/console/src/components/Tip/TipBubble/index.module.scss similarity index 91% rename from packages/console/src/components/TipBubble/index.module.scss rename to packages/console/src/components/Tip/TipBubble/index.module.scss index e312cf60d..5f8ab299b 100644 --- a/packages/console/src/components/TipBubble/index.module.scss +++ b/packages/console/src/components/Tip/TipBubble/index.module.scss @@ -10,6 +10,14 @@ font: var(--font-body-medium); max-width: 300px; + a { + color: #cabeff; + + &:active { + color: #cabeff; + } + } + &::after { content: ''; display: block; diff --git a/packages/console/src/components/TipBubble/index.tsx b/packages/console/src/components/Tip/TipBubble/index.tsx similarity index 100% rename from packages/console/src/components/TipBubble/index.tsx rename to packages/console/src/components/Tip/TipBubble/index.tsx diff --git a/packages/console/src/components/TipBubble/utils.ts b/packages/console/src/components/Tip/TipBubble/utils.ts similarity index 100% rename from packages/console/src/components/TipBubble/utils.ts rename to packages/console/src/components/Tip/TipBubble/utils.ts diff --git a/packages/console/src/components/ToggleTip/index.module.scss b/packages/console/src/components/Tip/ToggleTip/index.module.scss similarity index 100% rename from packages/console/src/components/ToggleTip/index.module.scss rename to packages/console/src/components/Tip/ToggleTip/index.module.scss diff --git a/packages/console/src/components/Tip/ToggleTip/index.tsx b/packages/console/src/components/Tip/ToggleTip/index.tsx new file mode 100644 index 000000000..da46eab14 --- /dev/null +++ b/packages/console/src/components/Tip/ToggleTip/index.tsx @@ -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(null); + const anchorRef = useRef(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 ( + <> +
{ + setIsOpen(true); + }} + onKeyDown={onKeyDownHandler(() => { + setIsOpen(true); + })} + > + {children} +
+ + + {typeof content === 'function' ? content(onClose) : content} + + + + ); +}; + +export default ToggleTip; diff --git a/packages/console/src/components/Tooltip/index.module.scss b/packages/console/src/components/Tip/Tooltip/index.module.scss similarity index 100% rename from packages/console/src/components/Tooltip/index.module.scss rename to packages/console/src/components/Tip/Tooltip/index.module.scss diff --git a/packages/console/src/components/Tooltip/index.tsx b/packages/console/src/components/Tip/Tooltip/index.tsx similarity index 74% rename from packages/console/src/components/Tooltip/index.tsx rename to packages/console/src/components/Tip/Tooltip/index.tsx index 3dcc7f7cb..c9aa037dc 100644 --- a/packages/console/src/components/Tooltip/index.tsx +++ b/packages/console/src/components/Tip/Tooltip/index.tsx @@ -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; - anchorRef: RefObject; 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(); + const anchorRef = useRef(null); const tooltipRef = useRef(null); const { @@ -119,25 +122,30 @@ const Tooltip = ({ useLayoutEffect(() => { mutate(); - }, [content, mutate]); + }, [mutate, content]); - if (!tooltipDom) { - return null; - } - - return createPortal( -
- -
{content}
-
-
, - tooltipDom + return ( + <> +
+ {children} +
+ {tooltipDom && + content && + createPortal( +
+ +
{content}
+
+
, + tooltipDom + )} + ); }; diff --git a/packages/console/src/components/Tip/index.ts b/packages/console/src/components/Tip/index.ts new file mode 100644 index 000000000..34c0f359d --- /dev/null +++ b/packages/console/src/components/Tip/index.ts @@ -0,0 +1,2 @@ +export { default as Tooltip } from './Tooltip'; +export { default as ToggleTip } from './ToggleTip'; diff --git a/packages/console/src/components/ToggleTip/index.tsx b/packages/console/src/components/ToggleTip/index.tsx deleted file mode 100644 index 1eba09dee..000000000 --- a/packages/console/src/components/ToggleTip/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { HTMLProps, ReactNode, RefObject } from 'react'; -import { useRef } from 'react'; -import ReactModal from 'react-modal'; - -import type { HorizontalAlignment } from '@/hooks/use-position'; -import usePosition from '@/hooks/use-position'; - -import type { TipBubblePosition } from '../TipBubble'; -import TipBubble from '../TipBubble'; -import { - getVerticalAlignment, - getHorizontalAlignment, - getVerticalOffset, - getHorizontalOffset, -} from '../TipBubble/utils'; -import * as styles from './index.module.scss'; - -type Props = HTMLProps & { - children: ReactNode; - isOpen: boolean; - onClose: () => void; - anchorRef: RefObject; - position?: TipBubblePosition; - horizontalAlign?: HorizontalAlignment; -}; - -const ToggleTip = ({ - children, - isOpen, - onClose, - anchorRef, - position = 'top', - horizontalAlign = 'start', -}: Props) => { - const overlayRef = useRef(null); - - const { - position: layoutPosition, - positionState, - mutate, - } = usePosition({ - verticalAlign: getVerticalAlignment(position), - horizontalAlign: getHorizontalAlignment(position, horizontalAlign), - offset: { - vertical: getVerticalOffset(position), - horizontal: getHorizontalOffset(position, horizontalAlign), - }, - anchorRef, - overlayRef, - }); - - return ( - - - {children} - - - ); -}; - -export default ToggleTip; diff --git a/packages/console/src/components/UnsavedChangesAlertModal/index.tsx b/packages/console/src/components/UnsavedChangesAlertModal/index.tsx index 68130e99e..42486418a 100644 --- a/packages/console/src/components/UnsavedChangesAlertModal/index.tsx +++ b/packages/console/src/components/UnsavedChangesAlertModal/index.tsx @@ -20,11 +20,19 @@ type BlockerNavigator = Navigator & { type Props = { hasUnsavedChanges: boolean; + parentPath?: string; }; -const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => { +const UnsavedChangesAlertModal = ({ hasUnsavedChanges, parentPath }: Props) => { const { navigator } = useContext(UNSAFE_NavigationContext); + /** + * Props `block` and `location` are removed from `Navigator` type in react-router, for the same reason as above. + * So we have to define our own type `BlockerNavigator` to acquire these props that actually exist in `navigator` object. + */ + // eslint-disable-next-line no-restricted-syntax + const { block, location } = navigator as BlockerNavigator; + const [displayAlert, setDisplayAlert] = useState(false); const [transition, setTransition] = useState(); @@ -35,12 +43,6 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => { return; } - /** - * Props `block` and `location` are removed from `Navigator` type in react-router, for the same reason as above. - * So we have to define our own type `BlockerNavigator` to acquire these props that actually exist in `navigator` object. - */ - // eslint-disable-next-line no-restricted-syntax - const { block, location } = navigator as BlockerNavigator; const { pathname } = location; const unblock = block((transition) => { @@ -53,6 +55,13 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => { return; } + if (parentPath && targetPathname.startsWith(parentPath)) { + unblock(); + transition.retry(); + + return; + } + setDisplayAlert(true); setTransition({ @@ -65,7 +74,7 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => { }); return unblock; - }, [navigator, hasUnsavedChanges]); + }, [navigator, hasUnsavedChanges, location, block, parentPath]); const leavePage = useCallback(() => { transition?.retry(); diff --git a/packages/console/src/hooks/use-connector-groups.ts b/packages/console/src/hooks/use-connector-groups.ts index f2ab53999..1c0a51f44 100644 --- a/packages/console/src/hooks/use-connector-groups.ts +++ b/packages/console/src/hooks/use-connector-groups.ts @@ -1,10 +1,9 @@ import type { ConnectorResponse } from '@logto/schemas'; -import { ConnectorType } from '@logto/schemas'; import { useMemo } from 'react'; import useSWR from 'swr'; import type { RequestError } from '@/hooks/use-api'; -import type { ConnectorGroup } from '@/types/connector'; +import { getConnectorGroups } from '@/pages/Connectors/utils'; // Group connectors by target const useConnectorGroups = () => { @@ -15,42 +14,7 @@ const useConnectorGroups = () => { return; } - return data.reduce((previous, item) => { - const groupIndex = previous.findIndex( - // Only group social connectors - ({ target }) => target === item.target && item.type === ConnectorType.Social - ); - - if (groupIndex === -1) { - return [ - ...previous, - { - id: item.id, // Take first connector's id as groupId, only used for indexing. - name: item.name, - logo: item.logo, - logoDark: item.logoDark, - description: item.description, - target: item.target, - type: item.type, - enabled: item.enabled, - connectors: [item], - }, - ]; - } - - return previous.map((group, index) => { - if (index !== groupIndex) { - return group; - } - - return { - ...group, - connectors: [...group.connectors, item], - // Group is enabled when any of its connectors is enabled. - enabled: group.enabled || item.enabled, - }; - }); - }, []); + return getConnectorGroups(data); }, [data]); return { diff --git a/packages/console/src/hooks/use-connector-in-use.ts b/packages/console/src/hooks/use-connector-in-use.ts index b30a99e80..85ec63740 100644 --- a/packages/console/src/hooks/use-connector-in-use.ts +++ b/packages/console/src/hooks/use-connector-in-use.ts @@ -1,5 +1,5 @@ import type { SignInExperience } from '@logto/schemas'; -import { SignUpIdentifier, SignInIdentifier, ConnectorType } from '@logto/schemas'; +import { SignInIdentifier, ConnectorType } from '@logto/schemas'; import useSWR from 'swr'; import type { RequestError } from './use-api'; @@ -17,7 +17,7 @@ const useConnectorInUse = (type?: ConnectorType, target?: string): boolean | und ({ identifier, verificationCode }) => verificationCode && identifier === SignInIdentifier.Email ) || - (data.signUp.identifier === SignUpIdentifier.Email && data.signUp.verify) + (data.signUp.identifiers.includes(SignInIdentifier.Email) && data.signUp.verify) ); } @@ -27,7 +27,7 @@ const useConnectorInUse = (type?: ConnectorType, target?: string): boolean | und ({ identifier, verificationCode }) => verificationCode && identifier === SignInIdentifier.Sms ) || - (data.signUp.identifier === SignUpIdentifier.Sms && data.signUp.verify) + (data.signUp.identifiers.includes(SignInIdentifier.Sms) && data.signUp.verify) ); } diff --git a/packages/console/src/hooks/use-enabled-connector-types.ts b/packages/console/src/hooks/use-enabled-connector-types.ts new file mode 100644 index 000000000..19910ed64 --- /dev/null +++ b/packages/console/src/hooks/use-enabled-connector-types.ts @@ -0,0 +1,25 @@ +import type { ConnectorResponse, ConnectorType } from '@logto/schemas'; +import { useCallback, useMemo } from 'react'; +import useSWR from 'swr'; + +import type { RequestError } from './use-api'; + +const useEnabledConnectorTypes = () => { + const { data: connectors } = useSWR('/api/connectors'); + + const enabledConnectorTypes = useMemo( + () => connectors?.map(({ type }) => type) ?? [], + [connectors] + ); + + const isConnectorTypeEnabled = useCallback( + (connectorType: ConnectorType) => enabledConnectorTypes.includes(connectorType), + [enabledConnectorTypes] + ); + + return { + isConnectorTypeEnabled, + }; +}; + +export default useEnabledConnectorTypes; diff --git a/packages/console/src/hooks/use-ui-languages.ts b/packages/console/src/hooks/use-ui-languages.ts index d30d37ad3..27d4ae30c 100644 --- a/packages/console/src/hooks/use-ui-languages.ts +++ b/packages/console/src/hooks/use-ui-languages.ts @@ -1,5 +1,6 @@ import type { LanguageTag } from '@logto/language-kit'; import { builtInLanguages as builtInUiLanguages } from '@logto/phrases-ui'; +import { deduplicate } from '@silverhand/essentials'; import { useCallback, useMemo } from 'react'; import useSWR from 'swr'; @@ -17,12 +18,10 @@ const useUiLanguages = () => { const languages = useMemo( () => - [ - ...new Set([ - ...builtInUiLanguages, - ...(customPhraseList?.map(({ languageTag }) => languageTag) ?? []), - ]), - ] + deduplicate([ + ...builtInUiLanguages, + ...(customPhraseList?.map(({ languageTag }) => languageTag) ?? []), + ]) .slice() .sort(), [customPhraseList] diff --git a/packages/console/src/mdx-components/Step/index.module.scss b/packages/console/src/mdx-components/Step/index.module.scss index 103b33813..366575c47 100644 --- a/packages/console/src/mdx-components/Step/index.module.scss +++ b/packages/console/src/mdx-components/Step/index.module.scss @@ -71,16 +71,6 @@ } } - a { - font: var(--font-body-medium); - color: var(--color-text-link); - text-decoration: none; - - &:hover { - border-bottom: 1px solid var(--color-text-link); - } - } - h3 { font: var(--font-title-medium); color: var(--color-text-secondary); diff --git a/packages/console/src/mdx-components/UriInputField/index.module.scss b/packages/console/src/mdx-components/UriInputField/index.module.scss index 05254a4b0..1b8cea632 100644 --- a/packages/console/src/mdx-components/UriInputField/index.module.scss +++ b/packages/console/src/mdx-components/UriInputField/index.module.scss @@ -5,13 +5,16 @@ align-items: flex-start; position: relative; + .field { + flex: 1; + + .multiTextInput { + flex: 1; + } + } + .saveButton { - position: absolute; - left: calc(556px + _.unit(3)); - top: 0; + flex-shrink: 0; + margin: _.unit(6) 0 0 _.unit(2); } } - -.field { - width: 556px; -} diff --git a/packages/console/src/mdx-components/UriInputField/index.tsx b/packages/console/src/mdx-components/UriInputField/index.tsx index 19c2f35e3..bb71ad9be 100644 --- a/packages/console/src/mdx-components/UriInputField/index.tsx +++ b/packages/console/src/mdx-components/UriInputField/index.tsx @@ -9,8 +9,8 @@ import useSWR from 'swr'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; -import MultiTextInput from '@/components/MultiTextInput'; import { convertRhfErrorMessage, createValidatorForRhf } from '@/components/MultiTextInput/utils'; +import MultiTextInputField from '@/components/MultiTextInputField'; import TextInput from '@/components/TextInput'; import type { RequestError } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; @@ -69,31 +69,33 @@ const UriInputField = ({ appId, name, title, isSingle = false }: Props) => { return (
- - !value || uriValidator(value), - message: t('errors.invalid_uri_format'), - }, - }), - }} - render={({ field: { onChange, value = [] }, fieldState: { error, isDirty } }) => { - const errorObject = convertRhfErrorMessage(error?.message); + !value || uriValidator(value), + message: t('errors.invalid_uri_format'), + }, + }), + }} + render={({ field: { onChange, value = [] }, fieldState: { error, isDirty } }) => { + const errorObject = convertRhfErrorMessage(error?.message); - return ( -
- {isSingle && ( + return ( +
+ {isSingle && ( + { onKeyPress(event, value); }} /> - )} - {!isSingle && ( - { - onKeyPress(event, value); - }} - /> - )} -
- ); - }} - /> - + )} +
+ ); + }} + />
); diff --git a/packages/console/src/pages/ApiResourceDetails/index.module.scss b/packages/console/src/pages/ApiResourceDetails/index.module.scss index 30126c32f..851905f7e 100644 --- a/packages/console/src/pages/ApiResourceDetails/index.module.scss +++ b/packages/console/src/pages/ApiResourceDetails/index.module.scss @@ -2,6 +2,7 @@ .backLink { margin: _.unit(1) 0 0 _.unit(1); + user-select: none; } .deleteConfirm { @@ -59,22 +60,3 @@ } } } - -.body { - > :first-child { - margin-top: 0; - } - - .form { - margin-top: _.unit(8); - } - - .fields { - padding-bottom: _.unit(10); - flex: 1; - } - - .textField { - @include _.form-text-field; - } -} diff --git a/packages/console/src/pages/ApiResourceDetails/index.tsx b/packages/console/src/pages/ApiResourceDetails/index.tsx index 10e8a8f34..8e07b4d93 100644 --- a/packages/console/src/pages/ApiResourceDetails/index.tsx +++ b/packages/console/src/pages/ApiResourceDetails/index.tsx @@ -1,7 +1,6 @@ import type { Resource } from '@logto/schemas'; import { AppearanceMode } from '@logto/schemas'; import { managementResource } from '@logto/schemas/lib/seeds'; -import classNames from 'classnames'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; @@ -15,15 +14,16 @@ import Back from '@/assets/images/back.svg'; import Delete from '@/assets/images/delete.svg'; import More from '@/assets/images/more.svg'; import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu'; -import Button from '@/components/Button'; import Card from '@/components/Card'; import CopyToClipboard from '@/components/CopyToClipboard'; import DeleteConfirmModal from '@/components/DeleteConfirmModal'; +import DetailsForm from '@/components/DetailsForm'; import DetailsSkeleton from '@/components/DetailsSkeleton'; +import FormCard from '@/components/FormCard'; import FormField from '@/components/FormField'; -import LinkButton from '@/components/LinkButton'; import TabNav, { TabNavItem } from '@/components/TabNav'; import TextInput from '@/components/TextInput'; +import TextLink from '@/components/TextLink'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import type { RequestError } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; @@ -105,12 +105,9 @@ const ApiResourceDetails = () => { return (
- } - title="api_resource_details.back_to_api_resources" - className={styles.backLink} - /> + } className={styles.backLink}> + {t('api_resource_details.back_to_api_resources')} + {isLoading && } {!data && error &&
{`error occurred: ${error.body?.message ?? error.message}`}
} {data && ( @@ -120,7 +117,7 @@ const ApiResourceDetails = () => {
{data.name}
- +
{!isLogtoManagementApiResource && ( @@ -159,47 +156,39 @@ const ApiResourceDetails = () => {
)} - - - {t('general.settings_nav')} - -
-
- - - - - - -
-
-
-
-
-
-
+ + {t('general.settings_nav')} + + + + + + + + + + + )} diff --git a/packages/console/src/pages/ApiResources/components/CreateForm/index.tsx b/packages/console/src/pages/ApiResources/components/CreateForm/index.tsx index b48344a8b..6d87757c5 100644 --- a/packages/console/src/pages/ApiResources/components/CreateForm/index.tsx +++ b/packages/console/src/pages/ApiResources/components/CreateForm/index.tsx @@ -1,11 +1,12 @@ import type { Resource } from '@logto/schemas'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; import ModalLayout from '@/components/ModalLayout'; import TextInput from '@/components/TextInput'; +import TextLink from '@/components/TextLink'; import useApi from '@/hooks/use-api'; type FormData = { @@ -64,7 +65,21 @@ const CreateForm = ({ onClose }: Props) => { ( + + ), + }} + > + {t('api_resources.api_identifier_tip')} + + )} > { const [apiResources, totalCount] = data ?? []; return ( - -
+
+
-
- - - - - - - - - - - - - {!data && error && ( - mutate(undefined, true)} - /> - )} - {isLoading && } - {apiResources?.length === 0 && ( - -
{t('api_resources.api_name')}{t('api_resources.api_identifier')}
+ + + + + + + + + + + + {!data && error && ( + mutate(undefined, true)} /> - - )} - {apiResources?.map(({ id, name, indicator }) => { - const ResourceIcon = - theme === AppearanceMode.LightMode ? ApiResource : ApiResourceDark; + )} + {isLoading && } + {apiResources?.length === 0 && ( + + { - navigate(buildDetailsLink(id)); - }} - > - - - - ); - })} - -
{t('api_resources.api_name')}{t('api_resources.api_identifier')}
- } - to={buildDetailsLink(id)} - /> - - -
+ return ( + { + navigate(buildDetailsLink(id)); + }} + > + + } + to={buildDetailsLink(id)} + /> + + + + + + ); + })} + + +
-
- {!!totalCount && ( - { - setQuery({ page: String(page) }); - }} - /> - )} -
- + { + setQuery({ page: String(page) }); + }} + /> +
); }; diff --git a/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx b/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx index 93fb75e8b..b2dc7d4e6 100644 --- a/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx +++ b/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx @@ -1,45 +1,49 @@ import type { Application, SnakeCaseOidcConfig } from '@logto/schemas'; import { ApplicationType, UserRole } from '@logto/schemas'; -import { useEffect } from 'react'; +import { deduplicate } from '@silverhand/essentials'; import { Controller, useFormContext } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import CopyToClipboard from '@/components/CopyToClipboard'; +import FormCard from '@/components/FormCard'; import FormField from '@/components/FormField'; import Switch from '@/components/Switch'; -import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; +import TextLink from '@/components/TextLink'; import * as styles from '../index.module.scss'; type Props = { applicationType: ApplicationType; oidcConfig: SnakeCaseOidcConfig; - defaultData: Application; - isDeleted: boolean; }; -const AdvancedSettings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props) => { - const { - control, - reset, - formState: { isDirty }, - } = useFormContext(); +const AdvancedSettings = ({ applicationType, oidcConfig }: Props) => { + const { control } = useFormContext(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - useEffect(() => { - reset(defaultData); - - return () => { - reset(defaultData); - }; - }, [reset, defaultData]); - return ( - <> + ( + + ), + }} + > + {t('application_details.authorization_endpoint_tip')} + + )} > { if (checked) { - onChange([...new Set(value.concat(UserRole.Admin))]); + onChange(deduplicate(value.concat(UserRole.Admin))); } else { onChange(value.filter((value) => value !== UserRole.Admin)); } @@ -83,8 +87,7 @@ const AdvancedSettings = ({ applicationType, oidcConfig, defaultData, isDeleted /> )} - - + ); }; diff --git a/packages/console/src/pages/ApplicationDetails/components/Settings.tsx b/packages/console/src/pages/ApplicationDetails/components/Settings.tsx index 8fb0e2fc1..558ef7ea0 100644 --- a/packages/console/src/pages/ApplicationDetails/components/Settings.tsx +++ b/packages/console/src/pages/ApplicationDetails/components/Settings.tsx @@ -1,43 +1,33 @@ -import type { Application, SnakeCaseOidcConfig } from '@logto/schemas'; +import type { Application } from '@logto/schemas'; import { ApplicationType, validateRedirectUrl } from '@logto/schemas'; -import { useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import CopyToClipboard from '@/components/CopyToClipboard'; +import FormCard from '@/components/FormCard'; import FormField from '@/components/FormField'; -import MultiTextInput from '@/components/MultiTextInput'; import type { MultiTextInputRule } from '@/components/MultiTextInput/types'; import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils'; +import MultiTextInputField from '@/components/MultiTextInputField'; import TextInput from '@/components/TextInput'; -import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; +import TextLink from '@/components/TextLink'; import { uriOriginValidator } from '@/utilities/validator'; import * as styles from '../index.module.scss'; type Props = { - applicationType: ApplicationType; - oidcConfig: SnakeCaseOidcConfig; - defaultData: Application; - isDeleted: boolean; + data: Application; }; -const Settings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props) => { +const Settings = ({ data }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { control, register, - reset, - formState: { errors, isDirty }, + formState: { errors }, } = useFormContext(); - useEffect(() => { - reset(defaultData); - - return () => { - reset(defaultData); - }; - }, [reset, defaultData]); + const { id, secret, type: applicationType } = data; const isNativeApp = applicationType === ApplicationType.Native; const uriPatternRules: MultiTextInputRule = { @@ -48,129 +38,158 @@ const Settings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props }; return ( - <> - + + - + - - + ( + + ), + }} + > + {t('application_details.application_id_tip')} + + )} + > + {[ApplicationType.Traditional, ApplicationType.MachineToMachine].includes( applicationType ) && ( - + )} {applicationType !== ApplicationType.MachineToMachine && ( - - ( - - )} - /> - + ( + ( + + ), + }} + > + {t('application_details.redirect_uri_tip')} + + )} + value={value} + error={convertRhfErrorMessage(error?.message)} + placeholder={ + applicationType === ApplicationType.Native + ? t('application_details.redirect_uri_placeholder_native') + : t('application_details.redirect_uri_placeholder') + } + onChange={onChange} + /> + )} + /> )} {applicationType !== ApplicationType.MachineToMachine && ( - - ( - - )} - /> - + ( + + )} + /> )} {applicationType !== ApplicationType.MachineToMachine && ( - - !value || uriOriginValidator(value), - message: t('errors.invalid_origin_format'), - }, - }), - }} - render={({ field: { onChange, value }, fieldState: { error } }) => ( - - )} - /> - + !value || uriOriginValidator(value), + message: t('errors.invalid_origin_format'), + }, + }), + }} + render={({ field: { onChange, value }, fieldState: { error } }) => ( + ( + + ), + }} + > + {t('application_details.cors_allowed_origins_tip')} + + )} + value={value} + error={convertRhfErrorMessage(error?.message)} + placeholder={t('application_details.cors_allowed_origins_placeholder')} + onChange={onChange} + /> + )} + /> )} - - + ); }; diff --git a/packages/console/src/pages/ApplicationDetails/index.module.scss b/packages/console/src/pages/ApplicationDetails/index.module.scss index 317dfabe5..db97a9e63 100644 --- a/packages/console/src/pages/ApplicationDetails/index.module.scss +++ b/packages/console/src/pages/ApplicationDetails/index.module.scss @@ -2,6 +2,7 @@ .backLink { margin: _.unit(1) 0 0 _.unit(1); + user-select: none; } .deleteConfirm { @@ -18,23 +19,8 @@ } } -.body { - > :first-child { - margin-top: 0; - } - - .form { - margin-top: _.unit(8); - } - - .fields { - padding-bottom: _.unit(10); - flex: 1; - } - - .textField { - @include _.form-text-field; - } +.textField { + @include _.form-text-field; } .header { diff --git a/packages/console/src/pages/ApplicationDetails/index.tsx b/packages/console/src/pages/ApplicationDetails/index.tsx index adf5952b5..b6264809e 100644 --- a/packages/console/src/pages/ApplicationDetails/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/index.tsx @@ -1,6 +1,5 @@ import type { Application, SnakeCaseOidcConfig } from '@logto/schemas'; import { ApplicationType } from '@logto/schemas'; -import classNames from 'classnames'; import { useEffect, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; @@ -17,10 +16,12 @@ import Button from '@/components/Button'; import Card from '@/components/Card'; import CopyToClipboard from '@/components/CopyToClipboard'; import DeleteConfirmModal from '@/components/DeleteConfirmModal'; +import DetailsForm from '@/components/DetailsForm'; import DetailsSkeleton from '@/components/DetailsSkeleton'; import Drawer from '@/components/Drawer'; -import LinkButton from '@/components/LinkButton'; import TabNav, { TabNavItem } from '@/components/TabNav'; +import TextLink from '@/components/TextLink'; +import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import type { RequestError } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; import useDocumentationUrl from '@/hooks/use-documentation-url'; @@ -62,7 +63,7 @@ const ApplicationDetails = () => { const { handleSubmit, reset, - formState: { isSubmitting }, + formState: { isSubmitting, isDirty }, } = formMethods; useEffect(() => { @@ -123,16 +124,11 @@ const ApplicationDetails = () => { setIsReadmeOpen(false); }; - const isAdvancedSettings = pathname.includes('advanced-settings'); - return (
- } - title="application_details.back_to_applications" - className={styles.backLink} - /> + } className={styles.backLink}> + {t('application_details.back_to_applications')} + {isLoading && } {data && oidcConfig && ( <> @@ -144,7 +140,7 @@ const ApplicationDetails = () => {
{t(`${applicationTypeI18nKey[data.type]}.title`)}
App ID
- +
@@ -200,51 +196,25 @@ const ApplicationDetails = () => {
- - - - {t('general.settings_nav')} - - - {t('application_details.advanced_settings')} - - - -
-
- {isAdvancedSettings && ( - - )} - {!isAdvancedSettings && ( - - )} -
-
-
-
-
-
-
-
+ + + {t('general.settings_nav')} + + + + + + + + )} +
); }; diff --git a/packages/console/src/pages/Applications/components/CreateForm/index.tsx b/packages/console/src/pages/Applications/components/CreateForm/index.tsx index ccf8c2722..ba5eee512 100644 --- a/packages/console/src/pages/Applications/components/CreateForm/index.tsx +++ b/packages/console/src/pages/Applications/components/CreateForm/index.tsx @@ -118,7 +118,12 @@ const CreateForm = ({ onClose }: Props) => { {createdApp && ( - + )} diff --git a/packages/console/src/pages/Applications/components/Guide/index.tsx b/packages/console/src/pages/Applications/components/Guide/index.tsx index 4f8701c7f..1aae09ea0 100644 --- a/packages/console/src/pages/Applications/components/Guide/index.tsx +++ b/packages/console/src/pages/Applications/components/Guide/index.tsx @@ -7,6 +7,7 @@ import type { LazyExoticComponent } from 'react'; import { cloneElement, lazy, Suspense, useEffect, useState } from 'react'; import CodeEditor from '@/components/CodeEditor'; +import TextLink from '@/components/TextLink'; import DetailsSummary from '@/mdx-components/DetailsSummary'; import type { SupportedSdk } from '@/types/applications'; import { applicationTypeAndSdkTypeMappings } from '@/types/applications'; @@ -97,9 +98,9 @@ const Guide = ({ app, isCompact, onClose }: Props) => { ); }, a: ({ children, ...props }) => ( - + {children} - + ), details: DetailsSummary, }} diff --git a/packages/console/src/pages/Applications/components/GuideHeader/index.module.scss b/packages/console/src/pages/Applications/components/GuideHeader/index.module.scss index b44ace2b3..449b08294 100644 --- a/packages/console/src/pages/Applications/components/GuideHeader/index.module.scss +++ b/packages/console/src/pages/Applications/components/GuideHeader/index.module.scss @@ -17,9 +17,11 @@ color: var(--color-text-secondary); } - .githubIcon { + .githubToolTipAnchor { margin-right: _.unit(4); + } + .githubIcon { div { display: flex; } diff --git a/packages/console/src/pages/Applications/components/GuideHeader/index.tsx b/packages/console/src/pages/Applications/components/GuideHeader/index.tsx index c9513f3b3..3ec1a9b32 100644 --- a/packages/console/src/pages/Applications/components/GuideHeader/index.tsx +++ b/packages/console/src/pages/Applications/components/GuideHeader/index.tsx @@ -1,4 +1,3 @@ -import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import Close from '@/assets/images/close.svg'; @@ -8,7 +7,7 @@ import CardTitle from '@/components/CardTitle'; import DangerousRaw from '@/components/DangerousRaw'; import IconButton from '@/components/IconButton'; import Spacer from '@/components/Spacer'; -import Tooltip from '@/components/Tooltip'; +import Tooltip from '@/components/Tip/Tooltip'; import { SupportedSdk } from '@/types/applications'; import * as styles from './index.module.scss'; @@ -47,7 +46,6 @@ const getSampleProjectUrl = (sdk: SupportedSdk) => { const GuideHeader = ({ appName, selectedSdk, isCompact = false, onClose }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const tipRef = useRef(null); const onClickGetSample = () => { const sampleUrl = getSampleProjectUrl(selectedSdk); @@ -64,12 +62,15 @@ const GuideHeader = ({ appName, selectedSdk, isCompact = false, onClose }: Props subtitle="applications.guide.header_description" /> - -
+ + -
- -
+ + diff --git a/packages/console/src/pages/Applications/index.module.scss b/packages/console/src/pages/Applications/index.module.scss index 34a50ba5d..e2565e3ea 100644 --- a/packages/console/src/pages/Applications/index.module.scss +++ b/packages/console/src/pages/Applications/index.module.scss @@ -1,26 +1,11 @@ @use '@/scss/underscore' as _; -.card { - @include _.flex-column; -} - -.headline { - display: flex; - justify-content: space-between; -} - -.table { - margin-top: _.unit(4); - flex: 1; -} - .icon { flex-shrink: 0; } .pagination { margin-top: _.unit(4); - min-height: 32px; } .applicationName { diff --git a/packages/console/src/pages/Applications/index.tsx b/packages/console/src/pages/Applications/index.tsx index ec1ad9967..52f65dc06 100644 --- a/packages/console/src/pages/Applications/index.tsx +++ b/packages/console/src/pages/Applications/index.tsx @@ -9,7 +9,6 @@ import useSWR from 'swr'; import Plus from '@/assets/images/plus.svg'; import ApplicationIcon from '@/components/ApplicationIcon'; import Button from '@/components/Button'; -import Card from '@/components/Card'; import CardTitle from '@/components/CardTitle'; import CopyToClipboard from '@/components/CopyToClipboard'; import ItemPreview from '@/components/ItemPreview'; @@ -19,6 +18,7 @@ import TableError from '@/components/Table/TableError'; import TableLoading from '@/components/Table/TableLoading'; import type { RequestError } from '@/hooks/use-api'; import * as modalStyles from '@/scss/modal.module.scss'; +import * as resourcesStyles from '@/scss/resources.module.scss'; import * as tableStyles from '@/scss/table.module.scss'; import { applicationTypeI18nKey } from '@/types/applications'; @@ -41,8 +41,8 @@ const Applications = () => { const [applications, totalCount] = data ?? []; return ( - -
+
+
-
- - - - - - - - - - - - - {!data && error && ( - mutate(undefined, true)} - /> - )} - {isLoading && } - {applications?.length === 0 && ( - - { - navigate(`/applications/${id}`); - }} - > - - + {' '} +
+
+
{t('applications.application_name')}{t('applications.app_id')}
- } - to={`/applications/${id}`} - /> - - -
+ + + + + + + + + - ))} - -
{t('applications.application_name')}{t('applications.app_id')}
+ + + + {!data && error && ( + mutate(undefined, true)} + /> + )} + {isLoading && } + {applications?.length === 0 && ( + +
-
- {!!totalCount && ( - { - setQuery({ page: String(page) }); - }} - /> - )} -
- + { + setQuery({ page: String(page) }); + }} + /> +
); }; diff --git a/packages/console/src/pages/AuditLogDetails/index.module.scss b/packages/console/src/pages/AuditLogDetails/index.module.scss index d671daac6..66f7b35dd 100644 --- a/packages/console/src/pages/AuditLogDetails/index.module.scss +++ b/packages/console/src/pages/AuditLogDetails/index.module.scss @@ -2,6 +2,7 @@ .backLink { margin: _.unit(1) 0 0 _.unit(1); + user-select: none; } .header { @@ -46,6 +47,8 @@ } .body { + margin-bottom: _.unit(6); + > :not(:first-child) { margin-top: _.unit(4); } diff --git a/packages/console/src/pages/AuditLogDetails/index.tsx b/packages/console/src/pages/AuditLogDetails/index.tsx index 173c49b6c..764c5dc77 100644 --- a/packages/console/src/pages/AuditLogDetails/index.tsx +++ b/packages/console/src/pages/AuditLogDetails/index.tsx @@ -8,11 +8,10 @@ import Back from '@/assets/images/back.svg'; import ApplicationName from '@/components/ApplicationName'; import Card from '@/components/Card'; import CodeEditor from '@/components/CodeEditor'; -import DangerousRaw from '@/components/DangerousRaw'; import DetailsSkeleton from '@/components/DetailsSkeleton'; import FormField from '@/components/FormField'; -import LinkButton from '@/components/LinkButton'; import TabNav, { TabNavItem } from '@/components/TabNav'; +import TextLink from '@/components/TextLink'; import UserName from '@/components/UserName'; import { logEventTitle } from '@/consts/logs'; import type { RequestError } from '@/hooks/use-api'; @@ -24,6 +23,9 @@ import * as styles from './index.module.scss'; const getAuditLogDetailsRelatedResourceLink = (pathname: string) => `/${pathname.slice(0, pathname.lastIndexOf('/'))}`; +const getDetailsTabNavLink = (logId: string, userId?: string) => + userId ? `/users/${userId}/logs/${logId}` : `/audit-logs/${logId}`; + const AuditLogDetails = () => { const { userId, logId } = useParams(); const { pathname } = useLocation(); @@ -34,17 +36,19 @@ const AuditLogDetails = () => { const isLoading = !data && !error; const backLink = getAuditLogDetailsRelatedResourceLink(pathname); - const backLinkTitle = userId ? ( - - {t('log_details.back_to_user', { name: userData?.name ?? t('users.unnamed') })} - - ) : ( - 'log_details.back_to_logs' - ); + const backLinkTitle = userId + ? t('log_details.back_to_user', { name: userData?.name ?? t('users.unnamed') }) + : t('log_details.back_to_logs'); + + if (!logId) { + return null; + } return (
- } title={backLinkTitle} className={styles.backLink} /> + } className={styles.backLink}> + {backLinkTitle} + {isLoading && } {!data && error &&
{`error occurred: ${error.body?.message ?? error.message}`}
} {data && ( @@ -95,12 +99,12 @@ const AuditLogDetails = () => {
+ + + {t('log_details.tab_details')} + + - - - {t('log_details.tab_details')} - -
diff --git a/packages/console/src/pages/AuditLogs/index.module.scss b/packages/console/src/pages/AuditLogs/index.module.scss deleted file mode 100644 index f5686478c..000000000 --- a/packages/console/src/pages/AuditLogs/index.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -@use '@/scss/underscore' as _; - -.card { - @include _.flex-column; -} - -.headline { - display: flex; - justify-content: space-between; -} diff --git a/packages/console/src/pages/AuditLogs/index.tsx b/packages/console/src/pages/AuditLogs/index.tsx index bc0104d57..d5bdc11bf 100644 --- a/packages/console/src/pages/AuditLogs/index.tsx +++ b/packages/console/src/pages/AuditLogs/index.tsx @@ -1,17 +1,17 @@ import AuditLogTable from '@/components/AuditLogTable'; -import Card from '@/components/Card'; import CardTitle from '@/components/CardTitle'; - -import * as styles from './index.module.scss'; +import * as resourcesStyles from '@/scss/resources.module.scss'; const AuditLogs = () => { return ( - -
+
+
- - +
+ +
+
); }; diff --git a/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx b/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx index d2b9390fc..baeb87e10 100644 --- a/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx +++ b/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx @@ -1,16 +1,17 @@ -import type { Connector, ConnectorResponse, ConnectorMetadata } from '@logto/schemas'; +import type { ConnectorResponse } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas'; -import { useMemo } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import Button from '@/components/Button'; -import CodeEditor from '@/components/CodeEditor'; -import FormField from '@/components/FormField'; +import DetailsForm from '@/components/DetailsForm'; +import FormCard from '@/components/FormCard'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import useApi from '@/hooks/use-api'; -import * as detailsStyles from '@/scss/details.module.scss'; +import ConnectorForm from '@/pages/Connectors/components/ConnectorForm'; +import type { ConnectorFormType } from '@/pages/Connectors/types'; +import { SyncProfileMode } from '@/pages/Connectors/types'; import { safeParseJson } from '@/utilities/json'; import * as styles from '../index.module.scss'; @@ -25,29 +26,40 @@ type Props = { const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const api = useApi(); - const methods = useForm<{ configJson: string }>({ reValidateMode: 'onBlur' }); + const methods = useForm({ + reValidateMode: 'onBlur', + defaultValues: { + syncProfile: SyncProfileMode.OnlyAtRegister, + }, + }); const { - control, formState: { isSubmitting, isDirty }, handleSubmit, watch, reset, } = methods; - const defaultConfig = useMemo(() => { - const hasData = Object.keys(connectorData.config).length > 0; + useEffect(() => { + const { name, logo, logoDark, target } = connectorData.metadata; + const { config, syncProfile } = connectorData; + reset({ + target, + logo, + logoDark: logoDark ?? '', + name: name?.en, + config: JSON.stringify(config, null, 2), + syncProfile: syncProfile ? SyncProfileMode.EachSignIn : SyncProfileMode.OnlyAtRegister, + }); + }, [connectorData, reset]); - return hasData ? JSON.stringify(connectorData.config, null, 2) : connectorData.configTemplate; - }, [connectorData]); - - const onSubmit = handleSubmit(async ({ configJson }) => { - if (!configJson) { + const onSubmit = handleSubmit(async ({ config, syncProfile, ...metadata }) => { + if (!config) { toast.error(t('connector_details.save_error_empty_config')); return; } - const result = safeParseJson(configJson); + const result = safeParseJson(config); if (!result.success) { toast.error(result.error); @@ -55,56 +67,55 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop return; } - const { metadata, ...rest } = await api - .patch(`/api/connectors/${connectorData.id}`, { json: { config: result.data } }) - .json(); + const payload = + connectorData.type === ConnectorType.Social + ? { + config: result.data, + syncProfile: syncProfile === SyncProfileMode.EachSignIn, + } + : { config: result.data }; + const standardConnectorPayload = { + ...payload, + metadata: { ...metadata, name: { en: metadata.name } }, + }; + const body = connectorData.isStandard ? standardConnectorPayload : payload; - onConnectorUpdated({ ...rest, ...metadata }); - reset({ configJson: JSON.stringify(result.data, null, 2) }); + const updatedConnector = await api + .patch(`/api/connectors/${connectorData.id}`, { + json: body, + }) + .json(); + + onConnectorUpdated(updatedConnector); toast.success(t('general.saved')); }); return ( - <> -
-
- ( - - - - )} - /> - - {connectorData.type !== ConnectorType.Social && ( - - )} -
-
-
-
-
+ + + + + {connectorData.type !== ConnectorType.Social && ( + + )} + + - + ); }; diff --git a/packages/console/src/pages/ConnectorDetails/components/ConnectorTabs/index.tsx b/packages/console/src/pages/ConnectorDetails/components/ConnectorTabs/index.tsx index f025dffb1..e6a97680e 100644 --- a/packages/console/src/pages/ConnectorDetails/components/ConnectorTabs/index.tsx +++ b/packages/console/src/pages/ConnectorDetails/components/ConnectorTabs/index.tsx @@ -1,7 +1,6 @@ import type { ConnectorResponse } from '@logto/schemas'; import { ConnectorPlatform } from '@logto/schemas'; import classNames from 'classnames'; -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import useSWR from 'swr'; @@ -18,15 +17,11 @@ type Props = { const ConnectorTabs = ({ target, connectorId }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { data } = useSWR(`/api/connectors?target=${target}`); + const { data: connectors } = useSWR(`/api/connectors?target=${target}`); - const connectors = useMemo(() => { - if (!data) { - return []; - } - - return data.filter(({ enabled }) => enabled); - }, [data]); + if (!connectors) { + return null; + } if (connectors.length === 0) { return null; diff --git a/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx b/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx index dc7a2fd3e..26eed1138 100644 --- a/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx +++ b/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx @@ -1,7 +1,8 @@ import { phoneRegEx, emailRegEx } from '@logto/core-kit'; import { ConnectorType } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import classNames from 'classnames'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; @@ -9,8 +10,9 @@ import { useTranslation } from 'react-i18next'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; import TextInput from '@/components/TextInput'; -import Tooltip from '@/components/Tooltip'; +import { Tooltip } from '@/components/Tip'; import useApi from '@/hooks/use-api'; +import { onKeyDownHandler } from '@/utilities/a11y'; import { safeParseJson } from '@/utilities/json'; import * as styles from './index.module.scss'; @@ -27,7 +29,6 @@ type FormData = { }; const SenderTester = ({ connectorId, connectorType, config, className }: Props) => { - const buttonPosReference = useRef(null); const [showTooltip, setShowTooltip] = useState(false); const { handleSubmit, @@ -57,6 +58,7 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props) const onSubmit = handleSubmit(async (formData) => { const { sendTo } = formData; + const result = safeParseJson(config); if (!result.success) { @@ -73,10 +75,9 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props) }); return ( -
+
-
+
- {showTooltip && ( - - )} +
{inputError?.message ?? t('connector_details.test_sender_description')}
- +
); }; diff --git a/packages/console/src/pages/ConnectorDetails/index.module.scss b/packages/console/src/pages/ConnectorDetails/index.module.scss index 9376e2471..dc61097b1 100644 --- a/packages/console/src/pages/ConnectorDetails/index.module.scss +++ b/packages/console/src/pages/ConnectorDetails/index.module.scss @@ -2,6 +2,7 @@ .backLink { margin: _.unit(1) 0 0 _.unit(1); + user-select: none; } .header { @@ -76,18 +77,12 @@ } } -.body { - > :not(:first-child) { - margin-top: _.unit(4); - } +.codeEditor { + margin-bottom: _.unit(6); +} - .main { - flex: 1; - - .codeEditor { - margin-bottom: _.unit(6); - } - } +.senderTest { + margin-top: _.unit(6); } .resetIcon { diff --git a/packages/console/src/pages/ConnectorDetails/index.tsx b/packages/console/src/pages/ConnectorDetails/index.tsx index 29ac6cdc0..42e7f7fb6 100644 --- a/packages/console/src/pages/ConnectorDetails/index.tsx +++ b/packages/console/src/pages/ConnectorDetails/index.tsx @@ -1,6 +1,5 @@ import type { ConnectorResponse } from '@logto/schemas'; import { AppearanceMode, ConnectorType } from '@logto/schemas'; -import classNames from 'classnames'; import { useState } from 'react'; import { toast } from 'react-hot-toast'; import { Trans, useTranslation } from 'react-i18next'; @@ -18,10 +17,10 @@ import ConfirmModal from '@/components/ConfirmModal'; import CopyToClipboard from '@/components/CopyToClipboard'; import DetailsSkeleton from '@/components/DetailsSkeleton'; import Drawer from '@/components/Drawer'; -import LinkButton from '@/components/LinkButton'; import Markdown from '@/components/Markdown'; import Status from '@/components/Status'; import TabNav, { TabNavItem } from '@/components/TabNav'; +import TextLink from '@/components/TextLink'; import UnnamedTrans from '@/components/UnnamedTrans'; import type { RequestError } from '@/hooks/use-api'; import useApi from '@/hooks/use-api'; @@ -68,16 +67,13 @@ const ConnectorDetails = () => { return; } - await api - .patch(`/api/connectors/${connectorId}/enabled`, { - json: { enabled: false }, - }) - .json(); - toast.success(t('connector_details.connector_deleted')); + await api.delete(`/api/connectors/${connectorId}`).json(); - await mutateGlobal('/api/connectors'); setIsDeleted(true); + toast.success(t('connector_details.connector_deleted')); + await mutateGlobal('/api/connectors'); + if (isSocial) { navigate(`/connectors/social`, { replace: true }); } else { @@ -87,12 +83,13 @@ const ConnectorDetails = () => { return (
- } - title="connector_details.back_to_connectors" className={styles.backLink} - /> + > + {t('connector_details.back_to_connectors')} + {isLoading && } {!data && error &&
{`error occurred: ${error.body?.message ?? error.message}`}
} {isSocial && } @@ -178,21 +175,19 @@ const ConnectorDetails = () => {
)} + + + {t('general.settings_nav')} + + {data && ( - - - - {t('general.settings_nav')} - - - { - void mutate(connector); - }} - /> - + { + void mutate(connector); + }} + /> )} {data && ( { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { configTemplate, isStandard } = connector; + const { + control, + register, + formState: { errors }, + } = useFormContext(); + const [darkVisible, setDarkVisible] = useState(false); + + const toggleDarkVisible = () => { + setDarkVisible((previous) => !previous); + }; + + const syncProfileOptions = [ + { + value: SyncProfileMode.OnlyAtRegister, + title: t('connectors.guide.sync_profile_only_at_register'), + }, + { + value: SyncProfileMode.EachSignIn, + title: t('connectors.guide.sync_profile_each_sign_in'), + }, + ]; + + return ( +
+ {isStandard && ( + <> + + +
{t('connectors.guide.name_tip')}
+
+ + +
{t('connectors.guide.logo_tip')}
+
+ {darkVisible && ( + + +
{t('connectors.guide.logo_dark_tip')}
+
+ )} +
)} - + ); }; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/SocialSignInForm.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/SocialSignInForm.tsx similarity index 91% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/SocialSignInForm.tsx rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/SocialSignInForm.tsx index 015f8b347..3e0137cb6 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/SocialSignInForm.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/SocialSignInForm.tsx @@ -1,18 +1,19 @@ import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import Card from '@/components/Card'; import FormField from '@/components/FormField'; import type { SignInExperienceForm } from '../../types'; +import * as styles from '../index.module.scss'; import SocialConnectorEditBox from './components/SocialConnectorEditBox'; -import * as styles from './index.module.scss'; const SocialSignInForm = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { control } = useFormContext(); return ( - <> +
{t('sign_in_exp.sign_up_and_sign_in.social_sign_in.title')}
@@ -30,7 +31,7 @@ const SocialSignInForm = () => { }} /> - +
); }; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/ConnectorSetupWarning.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/ConnectorSetupWarning.tsx new file mode 100644 index 000000000..0b5e38f93 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/ConnectorSetupWarning.tsx @@ -0,0 +1,49 @@ +import { ConnectorType } from '@logto/schemas'; +import { Trans, useTranslation } from 'react-i18next'; + +import Alert from '@/components/Alert'; +import TextLink from '@/components/TextLink'; +import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types'; + +type Props = { + requiredConnectors: ConnectorType[]; +}; + +const ConnectorSetupWarning = ({ requiredConnectors }: Props) => { + const { isConnectorTypeEnabled } = useEnabledConnectorTypes(); + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const missingConnectors = requiredConnectors.filter( + (connectorType) => !isConnectorTypeEnabled(connectorType) + ); + + if (missingConnectors.length === 0) { + return null; + } + + return ( + <> + {missingConnectors.map((connectorType) => ( + + + ), + }} + > + {t('sign_in_exp.setup_warning.no_connector', { + context: connectorType.toLowerCase(), + link: t('sign_in_exp.setup_warning.setup_link'), + })} + + + ))} + + ); +}; + +export default ConnectorSetupWarning; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/AddButton.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/AddButton.tsx similarity index 95% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/AddButton.tsx rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/AddButton.tsx index e0a516fe0..78e6e9280 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/AddButton.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/AddButton.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { snakeCase } from 'snake-case'; +import CirclePlus from '@/assets/images/circle-plus.svg'; import Plus from '@/assets/images/plus.svg'; import ActionMenu from '@/components/ActionMenu'; import type { Props as ButtonProps } from '@/components/Button'; @@ -33,6 +34,7 @@ const AddButton = ({ options, onSelected, hasSelectedIdentifiers }: Props) => { type: 'text', size: 'small', title: 'general.add_another', + icon: , }; return ( diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/SignInMethodItem.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/SignInMethodItem.tsx new file mode 100644 index 000000000..bceb6b4fd --- /dev/null +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/SignInMethodItem.tsx @@ -0,0 +1,120 @@ +import type { ConnectorType } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { snakeCase } from 'snake-case'; + +import Draggable from '@/assets/images/draggable.svg'; +import Minus from '@/assets/images/minus.svg'; +import SwitchArrowIcon from '@/assets/images/switch-arrow.svg'; +import Checkbox from '@/components/Checkbox'; +import IconButton from '@/components/IconButton'; +import { Tooltip } from '@/components/Tip'; +import type { SignInMethod } from '@/pages/SignInExperience/types'; + +import ConnectorSetupWarning from '../ConnectorSetupWarning'; +import * as styles from './index.module.scss'; + +type Props = { + signInMethod: SignInMethod; + isPasswordCheckable: boolean; + isVerificationCodeCheckable: boolean; + isDeletable: boolean; + requiredConnectors: ConnectorType[]; + hasError?: boolean; + errorMessage?: string; + onVerificationStateChange: ( + verification: 'password' | 'verificationCode', + checked: boolean + ) => void; + onToggleVerificationPrimary: () => void; + onDelete: () => void; +}; + +const SignInMethodItem = ({ + signInMethod: { identifier, password, verificationCode, isPasswordPrimary }, + isPasswordCheckable, + isVerificationCodeCheckable, + isDeletable, + requiredConnectors, + hasError, + errorMessage, + onVerificationStateChange, + onToggleVerificationPrimary, + onDelete, +}: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( +
+
+
+
+ + {t('sign_in_exp.sign_up_and_sign_in.identifiers', { + context: snakeCase(identifier), + })} +
+
+ { + onVerificationStateChange('password', checked); + }} + /> + {identifier !== SignInIdentifier.Username && ( + <> + + + + + + { + onVerificationStateChange('verificationCode', checked); + }} + /> + + )} +
+
+ + + + + +
+ {errorMessage &&
{errorMessage}
} + +
+ ); +}; + +export default SignInMethodItem; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/index.module.scss b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/index.module.scss similarity index 88% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/index.module.scss rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/index.module.scss index 6309679d5..dafeed18f 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/index.module.scss +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/index.module.scss @@ -22,6 +22,10 @@ cursor: move; color: var(--color-text); + &.error { + outline: 1px solid var(--color-error); + } + .identifier { width: 130px; display: flex; @@ -67,3 +71,8 @@ .addSignInMethodDropDown { min-width: unset; } + +.errorMessage { + font: var(--font-body-medium); + color: var(--color-error); +} diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/index.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/index.tsx new file mode 100644 index 000000000..927ba2ba5 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/index.tsx @@ -0,0 +1,159 @@ +import { SignInIdentifier } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import DragDropProvider from '@/components/Transfer/DragDropProvider'; +import DraggableItem from '@/components/Transfer/DraggableItem'; +import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types'; +import { + identifierRequiredConnectorMapping, + signInIdentifiers, + signUpIdentifiersMapping, +} from '@/pages/SignInExperience/constants'; +import type { SignInExperienceForm } from '@/pages/SignInExperience/types'; +import { getSignUpRequiredConnectorTypes } from '@/pages/SignInExperience/utils/identifier'; + +import AddButton from './AddButton'; +import SignInMethodItem from './SignInMethodItem'; +import * as styles from './index.module.scss'; +import { + getSignInMethodPasswordCheckState, + getSignInMethodVerificationCodeCheckState, +} from './utilities'; + +const SignInMethodEditBox = () => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { + control, + watch, + trigger, + formState: { submitCount }, + } = useFormContext(); + const signUp = watch('signUp'); + + const { fields, swap, update, remove, append } = useFieldArray({ + control, + name: 'signIn.methods', + }); + + const revalidate = () => { + if (submitCount) { + void trigger(`signIn.methods`); + } + }; + + const { isConnectorTypeEnabled } = useEnabledConnectorTypes(); + + if (!signUp) { + return null; + } + + const { + identifier: signUpIdentifier, + password: isSignUpPasswordRequired, + verify: isSignUpVerificationRequired, + } = signUp; + + const requiredSignInIdentifiers = signUpIdentifiersMapping[signUpIdentifier]; + const ignoredWarningConnectors = getSignUpRequiredConnectorTypes(signUpIdentifier); + + const signInIdentifierOptions = signInIdentifiers.filter((candidateIdentifier) => + fields.every(({ identifier }) => identifier !== candidateIdentifier) + ); + + return ( +
+ + {fields.map((signInMethod, index) => { + const { id, identifier, verificationCode, isPasswordPrimary } = signInMethod; + const signInRelatedConnector = identifierRequiredConnectorMapping[identifier]; + const requiredConnectors = + conditional( + verificationCode && + signInRelatedConnector && + !ignoredWarningConnectors.includes(signInRelatedConnector) && [ + signInRelatedConnector, + ] + ) ?? []; + + return ( + + { + if (!password && !verificationCode) { + return t('sign_in_exp.sign_up_and_sign_in.sign_in.require_auth_factor'); + } + + if ( + verificationCode && + requiredConnectors.some( + (connectorType) => !isConnectorTypeEnabled(connectorType) + ) + ) { + // Note: when required connectors are not all enabled, we show error state without error message for we have the connector setup warning + return false; + } + + return true; + }, + }} + render={({ field: { value }, fieldState: { error } }) => ( + { + update(index, { ...value, [verification]: checked }); + revalidate(); + }} + onToggleVerificationPrimary={() => { + update(index, { ...value, isPasswordPrimary: !isPasswordPrimary }); + revalidate(); + }} + onDelete={() => { + remove(index); + revalidate(); + }} + /> + )} + /> + + ); + })} + + 0} + onSelected={(identifier) => { + append({ + identifier, + password: getSignInMethodPasswordCheckState(identifier, isSignUpPasswordRequired), + verificationCode: getSignInMethodVerificationCodeCheckState(identifier), + isPasswordPrimary: true, + }); + revalidate(); + }} + /> +
+ ); +}; + +export default SignInMethodEditBox; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/utilities.ts b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/utilities.ts new file mode 100644 index 000000000..2ffc2a5a8 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SignInMethodEditBox/utilities.ts @@ -0,0 +1,17 @@ +import { SignInIdentifier } from '@logto/schemas'; + +export const getSignInMethodPasswordCheckState = ( + signInIdentifier: SignInIdentifier, + isSignUpPasswordRequired: boolean, + defaultCheckState = true +) => { + if (signInIdentifier === SignInIdentifier.Username) { + return true; + } + + return isSignUpPasswordRequired || defaultCheckState; +}; + +export const getSignInMethodVerificationCodeCheckState = (signInIdentifier: SignInIdentifier) => { + return signInIdentifier !== SignInIdentifier.Username; +}; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/AddButton.module.scss b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/AddButton.module.scss similarity index 100% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/AddButton.module.scss rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/AddButton.module.scss diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/AddButton.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/AddButton.tsx similarity index 86% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/AddButton.tsx rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/AddButton.tsx index a16d19fd1..08183c726 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/AddButton.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/AddButton.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; +import CirclePlus from '@/assets/images/circle-plus.svg'; import Plus from '@/assets/images/plus.svg'; import ActionMenu from '@/components/ActionMenu'; import type { Props as ButtonProps } from '@/components/Button'; @@ -32,6 +33,7 @@ const AddButton = ({ options, onSelected, hasSelectedConnectors }: Props) => { type: 'text', size: 'small', title: 'general.add_another', + icon: , }; return ( @@ -54,13 +56,11 @@ const AddButton = ({ options, onSelected, hasSelectedConnectors }: Props) => { {target} {connectors.length > 1 && - connectors - .filter(({ enabled }) => enabled) - .map(({ platform }) => ( -
- {platform && } -
- ))} + connectors.map(({ platform }) => ( +
+ {platform && } +
+ ))}
))} diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/SelectedConnectorItem.module.scss b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/SelectedConnectorItem.module.scss similarity index 100% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/SelectedConnectorItem.module.scss rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/SelectedConnectorItem.module.scss diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/SelectedConnectorItem.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/SelectedConnectorItem.tsx similarity index 79% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/SelectedConnectorItem.tsx rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/SelectedConnectorItem.tsx index fcce893f9..61e245964 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/SelectedConnectorItem.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/SelectedConnectorItem.tsx @@ -20,13 +20,11 @@ const SelectedConnectorItem = ({ data: { logo, target, name, connectors }, onDel {target} {connectors.length > 1 && - connectors - .filter(({ enabled }) => enabled) - .map(({ platform }) => ( -
- {platform && } -
- ))} + connectors.map(({ platform }) => ( +
+ {platform && } +
+ ))}
{ diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/index.module.scss b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/index.module.scss similarity index 75% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/index.module.scss rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/index.module.scss index ce48af402..486d98abf 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/index.module.scss +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/index.module.scss @@ -9,8 +9,7 @@ color: var(--color-text-secondary); margin-top: _.unit(2); - a { - color: var(--color-text-link); - text-decoration: none; + .setup { + margin: 0 _.unit(1); } } diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/index.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/index.tsx similarity index 91% rename from packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/index.tsx rename to packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/index.tsx index c9a46050c..b9cdc2fe5 100644 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SocialConnectorEditBox/index.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/components/SocialConnectorEditBox/index.tsx @@ -1,7 +1,7 @@ import { ConnectorType } from '@logto/schemas'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import TextLink from '@/components/TextLink'; import DragDropProvider from '@/components/Transfer/DragDropProvider'; import DraggableItem from '@/components/Transfer/DraggableItem'; import useConnectorGroups from '@/hooks/use-connector-groups'; @@ -54,8 +54,7 @@ const SocialConnectorEditBox = ({ value, onChange }: Props) => { .filter((item): item is ConnectorGroup => Boolean(item)); const connectorOptions = connectorData.filter( - ({ target, type, enabled }) => - !value.includes(target) && type === ConnectorType.Social && enabled + ({ target, type }) => !value.includes(target) && type === ConnectorType.Social ); return ( @@ -87,10 +86,10 @@ const SocialConnectorEditBox = ({ value, onChange }: Props) => { />
- {t('sign_in_exp.sign_up_and_sign_in.social_sign_in.set_up_hint.not_in_list')}{' '} - + {t('sign_in_exp.sign_up_and_sign_in.social_sign_in.set_up_hint.not_in_list')} + {t('sign_in_exp.sign_up_and_sign_in.social_sign_in.set_up_hint.set_up_more')} - {' '} + {t('sign_in_exp.sign_up_and_sign_in.social_sign_in.set_up_hint.go_to')}
diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/index.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/index.tsx new file mode 100644 index 000000000..78e496bed --- /dev/null +++ b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignIn/index.tsx @@ -0,0 +1,19 @@ +import TabWrapper from '../../components/TabWrapper'; +import * as styles from '../index.module.scss'; +import SignInForm from './SignInForm'; +import SignUpForm from './SignUpForm'; +import SocialSignInForm from './SocialSignInForm'; + +type Props = { + isActive: boolean; +}; + +const SignUpAndSignIn = ({ isActive }: Props) => ( + + + + + +); + +export default SignUpAndSignIn; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/SignInForm.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/SignInForm.tsx deleted file mode 100644 index fccd63813..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/SignInForm.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Controller, useFormContext } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; - -import FormField from '@/components/FormField'; - -import type { SignInExperienceForm } from '../../types'; -import SignInMethodEditBox from './components/SignInMethodEditBox'; -import { - signUpIdentifierToRequiredConnectorMapping, - signUpToSignInIdentifierMapping, -} from './constants'; -import * as styles from './index.module.scss'; - -const SignInForm = () => { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - - const { control, watch } = useFormContext(); - - const signUpIdentifier = watch('signUp.identifier'); - const setupPasswordAtSignUp = watch('signUp.password'); - const setupVerificationAtSignUp = watch('signUp.verify'); - - if ( - !signUpIdentifier || - setupPasswordAtSignUp === undefined || - setupVerificationAtSignUp === undefined - ) { - return null; - } - - return ( - <> -
{t('sign_in_exp.sign_up_and_sign_in.sign_in.title')}
- -
- {t('sign_in_exp.sign_up_and_sign_in.sign_in.description')} -
- { - return ( - - ); - }} - /> -
- - ); -}; - -export default SignInForm; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/ConnectorSetupWarning.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/ConnectorSetupWarning.tsx deleted file mode 100644 index fa109e7dc..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/ConnectorSetupWarning.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { ConnectorResponse } from '@logto/schemas'; -import { ConnectorType } from '@logto/schemas'; -import { useTranslation } from 'react-i18next'; -import useSWR from 'swr'; - -import Alert from '@/components/Alert'; -import type { RequestError } from '@/hooks/use-api'; - -type Props = { - requiredConnectors: ConnectorType[]; -}; - -const ConnectorSetupWarning = ({ requiredConnectors }: Props) => { - const { data: connectors } = useSWR('/api/connectors'); - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - - if (!connectors) { - return null; - } - - const missingConnectors = requiredConnectors.filter( - (connectorType) => !connectors.some(({ type, enabled }) => type === connectorType && enabled) - ); - - if (missingConnectors.length === 0) { - return null; - } - - return ( - <> - {missingConnectors.map((connectorType) => ( - - {t('sign_in_exp.setup_warning.no_connector', { - context: connectorType.toLowerCase(), - })} - - ))} - - ); -}; - -export default ConnectorSetupWarning; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/SignInMethodItem.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/SignInMethodItem.tsx deleted file mode 100644 index 34c73c463..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/SignInMethodItem.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { SignInIdentifier } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; -import classNames from 'classnames'; -import { useTranslation } from 'react-i18next'; -import { snakeCase } from 'snake-case'; - -import Draggable from '@/assets/images/draggable.svg'; -import Minus from '@/assets/images/minus.svg'; -import SwitchArrowIcon from '@/assets/images/switch-arrow.svg'; -import Checkbox from '@/components/Checkbox'; -import IconButton from '@/components/IconButton'; - -import * as styles from './index.module.scss'; -import type { SignInMethod } from './types'; - -type Props = { - signInMethod: SignInMethod; - isPasswordCheckable: boolean; - isVerificationCodeCheckable: boolean; - isDeletable: boolean; - onVerificationStateChange: ( - identifier: SignInIdentifier, - verification: 'password' | 'verificationCode', - checked: boolean - ) => void; - onToggleVerificationPrimary: (identifier: SignInIdentifier) => void; - onDelete: (identifier: SignInIdentifier) => void; -}; - -const SignInMethodItem = ({ - signInMethod: { identifier, password, verificationCode, isPasswordPrimary }, - isPasswordCheckable, - isVerificationCodeCheckable, - isDeletable, - onVerificationStateChange, - onToggleVerificationPrimary, - onDelete, -}: Props) => { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - - return ( -
-
-
- - {t('sign_in_exp.sign_up_and_sign_in.identifiers', { - context: snakeCase(identifier), - })} -
-
- { - onVerificationStateChange(identifier, 'password', checked); - }} - /> - {identifier !== SignInIdentifier.Username && ( - <> - { - onToggleVerificationPrimary(identifier); - }} - > - - - { - onVerificationStateChange(identifier, 'verificationCode', checked); - }} - /> - - )} -
-
- { - onDelete(identifier); - }} - > - - -
- ); -}; - -export default SignInMethodItem; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/index.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/index.tsx deleted file mode 100644 index fbc8f105e..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/index.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { ConnectorType } from '@logto/schemas'; -import { SignInIdentifier } from '@logto/schemas'; -import { useCallback, useEffect, useRef } from 'react'; - -import DragDropProvider from '@/components/Transfer/DragDropProvider'; -import DraggableItem from '@/components/Transfer/DraggableItem'; - -import { signInIdentifiers, signInIdentifierToRequiredConnectorMapping } from '../../constants'; -import ConnectorSetupWarning from '../ConnectorSetupWarning'; -import AddButton from './AddButton'; -import SignInMethodItem from './SignInMethodItem'; -import * as styles from './index.module.scss'; -import type { SignInMethod } from './types'; -import { - computeOnSignInMethodAppended, - computeOnVerificationStateChanged, - computeOnPasswordPrimaryFlagToggled, - getSignInMethodPasswordCheckState, - getSignInMethodVerificationCodeCheckState, -} from './utilities'; - -type Props = { - value: SignInMethod[]; - onChange: (value: SignInMethod[]) => void; - requiredSignInIdentifiers: SignInIdentifier[]; - ignoredWarningConnectors: ConnectorType[]; - isSignUpPasswordRequired: boolean; - isSignUpVerificationRequired: boolean; -}; - -const SignInMethodEditBox = ({ - value, - onChange, - requiredSignInIdentifiers, - ignoredWarningConnectors, - isSignUpPasswordRequired, - isSignUpVerificationRequired, -}: Props) => { - const signInIdentifierOptions = signInIdentifiers.filter((candidateIdentifier) => - value.every(({ identifier }) => identifier !== candidateIdentifier) - ); - - // Note: add a reference to avoid infinite loop when change the value by `useEffect` - const signInMethods = useRef(value); - - const handleChange = useCallback( - (value: SignInMethod[]) => { - // eslint-disable-next-line @silverhand/fp/no-mutation - signInMethods.current = value; - onChange(value); - }, - [onChange] - ); - - const addSignInMethod = useCallback( - (identifier: SignInIdentifier) => { - handleChange( - computeOnSignInMethodAppended(value, { - identifier, - password: getSignInMethodPasswordCheckState(identifier, isSignUpPasswordRequired), - verificationCode: getSignInMethodVerificationCodeCheckState(identifier), - isPasswordPrimary: true, - }) - ); - }, - [handleChange, value, isSignUpPasswordRequired] - ); - - useEffect(() => { - const allSignInMethods = requiredSignInIdentifiers.reduce( - (previous, current) => - computeOnSignInMethodAppended(previous, { - identifier: current, - password: getSignInMethodPasswordCheckState(current, isSignUpPasswordRequired), - verificationCode: getSignInMethodVerificationCodeCheckState(current), - isPasswordPrimary: true, - }), - signInMethods.current - ); - - handleChange( - allSignInMethods.map((method) => ({ - ...method, - password: getSignInMethodPasswordCheckState( - method.identifier, - isSignUpPasswordRequired, - method.password - ), - verificationCode: getSignInMethodVerificationCodeCheckState(method.identifier), - })) - ); - }, [ - handleChange, - isSignUpPasswordRequired, - isSignUpVerificationRequired, - requiredSignInIdentifiers, - ]); - - const onMoveItem = (dragIndex: number, hoverIndex: number) => { - const dragItem = value[dragIndex]; - const hoverItem = value[hoverIndex]; - - if (!dragItem || !hoverItem) { - return; - } - - handleChange( - value.map((value_, index) => { - if (index === dragIndex) { - return hoverItem; - } - - if (index === hoverIndex) { - return dragItem; - } - - return value_; - }) - ); - }; - - return ( -
- - {value.map((signInMethod, index) => ( - - { - handleChange( - computeOnVerificationStateChanged(value, identifier, verification, checked) - ); - }} - onToggleVerificationPrimary={(identifier) => { - handleChange(computeOnPasswordPrimaryFlagToggled(value, identifier)); - }} - onDelete={(identifier) => { - handleChange(value.filter((method) => method.identifier !== identifier)); - }} - /> - - ))} - - ( - (connectors, { identifier: signInIdentifier, verificationCode }) => { - return [ - ...connectors, - ...(verificationCode - ? signInIdentifierToRequiredConnectorMapping[signInIdentifier] - : []), - ]; - }, - [] - ) - .filter((connector) => !ignoredWarningConnectors.includes(connector))} - /> - 0} - onSelected={addSignInMethod} - /> -
- ); -}; - -export default SignInMethodEditBox; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/types.ts b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/types.ts deleted file mode 100644 index 75d9dc12e..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SignInExperience } from '@logto/schemas'; - -export type SignInMethod = SignInExperience['signIn']['methods'][number]; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/utilities.ts b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/utilities.ts deleted file mode 100644 index ab1721273..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignInMethodEditBox/utilities.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SignInIdentifier } from '@logto/schemas'; - -import type { SignInMethod } from './types'; - -export const computeOnVerificationStateChanged = ( - oldValue: SignInMethod[], - identifier: SignInIdentifier, - verification: 'password' | 'verificationCode', - checked: boolean -) => - oldValue.map((method) => - method.identifier === identifier - ? { - ...method, - [verification]: checked, - } - : method - ); - -export const computeOnSignInMethodAppended = ( - appendTo: SignInMethod[], - appended: SignInMethod -): SignInMethod[] => { - const { identifier: signInIdentifier } = appended; - - if (appendTo.some((method) => method.identifier === signInIdentifier)) { - return appendTo; - } - - return [...appendTo, appended]; -}; - -export const computeOnPasswordPrimaryFlagToggled = ( - oldValue: SignInMethod[], - identifier: SignInIdentifier -) => - oldValue.map((method) => - method.identifier === identifier - ? { - ...method, - isPasswordPrimary: !method.isPasswordPrimary, - } - : method - ); - -export const getSignInMethodPasswordCheckState = ( - signInIdentifier: SignInIdentifier, - isSignUpPasswordRequired: boolean, - defaultCheckState = true -) => { - if (signInIdentifier === SignInIdentifier.Username) { - return true; - } - - return isSignUpPasswordRequired || defaultCheckState; -}; - -export const getSignInMethodVerificationCodeCheckState = (signInIdentifier: SignInIdentifier) => { - return signInIdentifier !== SignInIdentifier.Username; -}; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/SignInDiffSection.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/SignInDiffSection.tsx deleted file mode 100644 index 9db5d2258..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/SignInDiffSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { SignInIdentifier } from '@logto/schemas'; -import { detailedDiff } from 'deep-object-diff'; -import get from 'lodash.get'; -import { useTranslation } from 'react-i18next'; - -import type { SignInMethod } from '../SignInMethodEditBox/types'; -import DiffSegment from './DiffSegment'; -import * as styles from './index.module.scss'; -import type { SignInMethodsObject } from './types'; -import { convertToSignInMethodsObject } from './utilities'; - -type Props = { - before: SignInMethod[]; - after: SignInMethod[]; - isAfter?: boolean; -}; - -const SignInDiffSection = ({ before, after, isAfter = false }: Props) => { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - - const beforeSignInMethodsObject = convertToSignInMethodsObject(before); - const afterSignInMethodsObject = convertToSignInMethodsObject(after); - - const signInDiff = isAfter - ? detailedDiff(beforeSignInMethodsObject, afterSignInMethodsObject) - : detailedDiff(afterSignInMethodsObject, beforeSignInMethodsObject); - - const displaySignInMethodsObject = isAfter ? afterSignInMethodsObject : beforeSignInMethodsObject; - - const hasIdentifierChanged = (identifierKey: SignInIdentifier) => - get(signInDiff, `added.${identifierKey.toLocaleLowerCase()}`) !== undefined; - - const hasAuthenticationChanged = ( - identifierKey: SignInIdentifier, - authenticationKey: keyof SignInMethodsObject[SignInIdentifier] - ) => - get(signInDiff, `updated.${identifierKey.toLocaleLowerCase()}.${authenticationKey}`) !== - undefined; - - return ( -
-
{t('sign_in_exp.save_alert.sign_in')}
-
    - { - // eslint-disable-next-line no-restricted-syntax - (Object.keys(displaySignInMethodsObject).slice().sort() as SignInIdentifier[]).map( - (identifierKey) => { - const { password, verificationCode } = displaySignInMethodsObject[identifierKey]; - const hasAuthentication = password || verificationCode; - const needDisjunction = password && verificationCode; - - return ( -
  • - - {t('sign_in_exp.sign_up_and_sign_in.identifiers', { - context: identifierKey.toLocaleLowerCase(), - })} - {hasAuthentication && ' ('} - {password && ( - - {t('sign_in_exp.sign_up_and_sign_in.sign_in.password_auth')} - - )} - {needDisjunction && ` ${String(t('sign_in_exp.sign_up_and_sign_in.or'))} `} - {verificationCode && ( - - {needDisjunction - ? t( - 'sign_in_exp.sign_up_and_sign_in.sign_in.verification_code_auth' - ).toLocaleLowerCase() - : t('sign_in_exp.sign_up_and_sign_in.sign_in.verification_code_auth')} - - )} - {hasAuthentication && ')'} - -
  • - ); - } - ) - } -
-
- ); -}; - -export default SignInDiffSection; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/types.ts b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/types.ts deleted file mode 100644 index 30a9b5f7f..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { SignInIdentifier } from '@logto/schemas'; - -export type SignInMethodsObject = Record< - SignInIdentifier, - { password: boolean; verificationCode: boolean } ->; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/constants.ts b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/constants.ts deleted file mode 100644 index 1c1171c59..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/constants.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { SignUpIdentifier, SignInIdentifier, ConnectorType } from '@logto/schemas'; - -export const signUpIdentifiers = Object.values(SignUpIdentifier); - -export const signInIdentifiers = Object.values(SignInIdentifier); - -export const requiredVerifySignUpIdentifiers = [ - SignUpIdentifier.Email, - SignUpIdentifier.Sms, - SignUpIdentifier.EmailOrSms, -]; - -export const signUpToSignInIdentifierMapping: { [key in SignUpIdentifier]: SignInIdentifier[] } = { - [SignUpIdentifier.Username]: [SignInIdentifier.Username], - [SignUpIdentifier.Email]: [SignInIdentifier.Email], - [SignUpIdentifier.Sms]: [SignInIdentifier.Sms], - [SignUpIdentifier.EmailOrSms]: [SignInIdentifier.Email, SignInIdentifier.Sms], - [SignUpIdentifier.None]: [], -}; - -export const signUpIdentifierToRequiredConnectorMapping: { - [key in SignUpIdentifier]: ConnectorType[]; -} = { - [SignUpIdentifier.Username]: [], - [SignUpIdentifier.Email]: [ConnectorType.Email], - [SignUpIdentifier.Sms]: [ConnectorType.Sms], - [SignUpIdentifier.EmailOrSms]: [ConnectorType.Email, ConnectorType.Sms], - [SignUpIdentifier.None]: [], -}; - -export const signInIdentifierToRequiredConnectorMapping: { - [key in SignInIdentifier]: ConnectorType[]; -} = { - [SignInIdentifier.Username]: [], - [SignInIdentifier.Email]: [ConnectorType.Email], - [SignInIdentifier.Sms]: [ConnectorType.Sms], -}; diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/index.module.scss b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/index.module.scss deleted file mode 100644 index e4c4ba76b..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/index.module.scss +++ /dev/null @@ -1,28 +0,0 @@ -@use '@/scss/underscore' as _; - -.title { - @include _.subhead-cap; - color: var(--color-neutral-variant-60); - margin-top: _.unit(12); - - &:first-child { - margin-top: _.unit(6); - } -} - -.formFieldDescription { - font: var(--font-body-medium); - color: var(--color-text-secondary); - margin: _.unit(1) 0 _.unit(2); -} - -.socialOnlyDescription { - margin-left: _.unit(1); - color: var(--color-text-secondary); -} - -.selections { - > :not(:first-child) { - margin-top: _.unit(3); - } -} diff --git a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/index.tsx b/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/index.tsx deleted file mode 100644 index c0e6c63b1..000000000 --- a/packages/console/src/pages/SignInExperience/tabs/SignUpAndSignInTab/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import SignInForm from './SignInForm'; -import SignUpForm from './SignUpForm'; -import SocialSignInForm from './SocialSignInForm'; - -const SignUpAndSignInTab = () => ( - <> - - - - -); - -export default SignUpAndSignInTab; diff --git a/packages/console/src/pages/SignInExperience/components/index.module.scss b/packages/console/src/pages/SignInExperience/tabs/index.module.scss similarity index 59% rename from packages/console/src/pages/SignInExperience/components/index.module.scss rename to packages/console/src/pages/SignInExperience/tabs/index.module.scss index c12274bf0..09c9d0bf3 100644 --- a/packages/console/src/pages/SignInExperience/components/index.module.scss +++ b/packages/console/src/pages/SignInExperience/tabs/index.module.scss @@ -1,12 +1,34 @@ @use '@/scss/underscore' as _; +.tabContent { + > :not(:first-child) { + margin-top: _.unit(3); + } +} + .title { @include _.subhead-cap; color: var(--color-neutral-variant-60); - margin-top: _.unit(12); +} - &:first-child { - margin-top: _.unit(6); +.radioGroup { + margin-top: _.unit(3); +} + +.formFieldDescription { + font: var(--font-body-medium); + color: var(--color-text-secondary); + margin: _.unit(1) 0 _.unit(2); +} + +.socialOnlyDescription { + margin-left: _.unit(1); + color: var(--color-text-secondary); +} + +.selections { + > :not(:first-child) { + margin-top: _.unit(3); } } diff --git a/packages/console/src/pages/SignInExperience/types.ts b/packages/console/src/pages/SignInExperience/types.ts index 661475158..bccf2c1ea 100644 --- a/packages/console/src/pages/SignInExperience/types.ts +++ b/packages/console/src/pages/SignInExperience/types.ts @@ -1,6 +1,25 @@ -import type { SignInExperience, SignUp } from '@logto/schemas'; +import type { SignInExperience, SignInIdentifier, SignUp } from '@logto/schemas'; -export type SignInExperienceForm = Omit & { - signUp: Partial; +export enum SignUpIdentifier { + Email = 'email', + Sms = 'sms', + Username = 'username', + EmailOrSms = 'emailOrSms', + None = 'none', +} + +export type SignUpForm = Omit & { + identifier: SignUpIdentifier; +}; + +export type SignInExperienceForm = Omit & { + signUp?: SignUpForm; createAccountEnabled: boolean; }; + +export type SignInMethod = SignInExperience['signIn']['methods'][number]; + +export type SignInMethodsObject = Record< + SignInIdentifier, + { password: boolean; verificationCode: boolean } +>; diff --git a/packages/console/src/pages/SignInExperience/utilities.ts b/packages/console/src/pages/SignInExperience/utilities.ts deleted file mode 100644 index 288099f66..000000000 --- a/packages/console/src/pages/SignInExperience/utilities.ts +++ /dev/null @@ -1,86 +0,0 @@ -import en from '@logto/phrases-ui/lib/locales/en'; -import type { SignInExperience, Translation } from '@logto/schemas'; -import { SignUpIdentifier, SignInMode } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; - -import { - isSignInMethodsDifferent, - isSignUpDifferent, - isSocialTargetsDifferent, -} from './tabs/SignUpAndSignInTab/components/SignUpAndSignInDiffSection/utilities'; -import type { SignInExperienceForm } from './types'; - -export const signInExperienceParser = { - toLocalForm: (signInExperience: SignInExperience): SignInExperienceForm => { - const { signInMode } = signInExperience; - - return { - ...signInExperience, - createAccountEnabled: signInMode !== SignInMode.SignIn, - }; - }, - toRemoteModel: (setup: SignInExperienceForm): SignInExperience => { - const { branding, createAccountEnabled, signUp } = setup; - - return { - ...setup, - branding: { - ...branding, - // Transform empty string to undefined - darkLogoUrl: conditional(branding.darkLogoUrl?.length && branding.darkLogoUrl), - slogan: conditional(branding.slogan?.length && branding.slogan), - }, - signUp: { - identifier: signUp.identifier ?? SignUpIdentifier.Username, - password: Boolean(signUp.password), - verify: Boolean(signUp.verify), - }, - signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn, - }; - }, -}; - -export const compareSignUpAndSignInConfigs = ( - before: SignInExperience, - after: SignInExperience -): boolean => { - return ( - !isSignUpDifferent(before.signUp, after.signUp) && - !isSignInMethodsDifferent(before.signIn.methods, after.signIn.methods) && - !isSocialTargetsDifferent( - before.socialSignInConnectorTargets, - after.socialSignInConnectorTargets - ) - ); -}; - -export const flattenTranslation = ( - translation: Translation, - keyPrefix = '' -): Record => - Object.keys(translation).reduce((result, key) => { - const prefix = keyPrefix ? `${keyPrefix}.` : keyPrefix; - const unwrappedKey = `${prefix}${key}`; - const unwrapped = translation[key]; - - return unwrapped === undefined - ? result - : { - ...result, - ...(typeof unwrapped === 'string' - ? { [unwrappedKey]: unwrapped } - : flattenTranslation(unwrapped, unwrappedKey)), - }; - }, {}); - -const emptyTranslation = (translation: Translation): Translation => - Object.entries(translation).reduce((result, [key, value]) => { - return typeof value === 'string' - ? { ...result, [key]: '' } - : { - ...result, - [key]: emptyTranslation(value), - }; - }, {}); - -export const createEmptyUiTranslation = () => emptyTranslation(en.translation); diff --git a/packages/console/src/pages/SignInExperience/utils/form.ts b/packages/console/src/pages/SignInExperience/utils/form.ts new file mode 100644 index 000000000..65fb0c653 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/utils/form.ts @@ -0,0 +1,124 @@ +import type { SignInExperience, SignUp } from '@logto/schemas'; +import { SignInMode, SignInIdentifier } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import type { DeepRequired, FieldErrorsImpl } from 'react-hook-form'; + +import { + hasSignInMethodsChanged, + hasSignUpSettingsChanged, + hasSocialTargetsChanged, +} from '../components/SignUpAndSignInChangePreview/SignUpAndSignInDiffSection/utilities'; +import { signUpIdentifiersMapping } from '../constants'; +import { SignUpIdentifier } from '../types'; +import type { SignInExperienceForm, SignUpForm } from '../types'; +import { mapIdentifiersToSignUpIdentifier } from './identifier'; + +export const signInExperienceParser = { + toLocalSignUp: (signUp: SignUp): SignUpForm => { + const { identifiers, ...signUpData } = signUp; + + return { + identifier: mapIdentifiersToSignUpIdentifier(identifiers), + ...signUpData, + }; + }, + toRemoteSignUp: (signUpForm: SignUpForm): SignUp => { + const { identifier, ...signUpFormData } = signUpForm; + + return { + identifiers: signUpIdentifiersMapping[identifier], + ...signUpFormData, + }; + }, + toLocalForm: (signInExperience: SignInExperience): SignInExperienceForm => { + const { signUp, signInMode } = signInExperience; + + return { + ...signInExperience, + signUp: signInExperienceParser.toLocalSignUp(signUp), + createAccountEnabled: signInMode !== SignInMode.SignIn, + }; + }, + toRemoteModel: (setup: SignInExperienceForm): SignInExperience => { + const { branding, createAccountEnabled, signUp } = setup; + + return { + ...setup, + branding: { + ...branding, + // Transform empty string to undefined + darkLogoUrl: conditional(branding.darkLogoUrl?.length && branding.darkLogoUrl), + slogan: conditional(branding.slogan?.length && branding.slogan), + }, + signUp: signUp + ? signInExperienceParser.toRemoteSignUp(signUp) + : { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signInMode: createAccountEnabled ? SignInMode.SignInAndRegister : SignInMode.SignIn, + }; + }, +}; + +export const hasSignUpAndSignInConfigChanged = ( + before: SignInExperience, + after: SignInExperience +): boolean => { + return ( + !hasSignUpSettingsChanged(before.signUp, after.signUp) && + !hasSignInMethodsChanged(before.signIn.methods, after.signIn.methods) && + !hasSocialTargetsChanged( + before.socialSignInConnectorTargets, + after.socialSignInConnectorTargets + ) + ); +}; + +export const getBrandingErrorCount = ( + errors: FieldErrorsImpl> +) => { + const { color, branding } = errors; + const colorFormErrorCount = color ? Object.keys(color).length : 0; + const brandingFormErrorCount = branding ? Object.keys(branding).length : 0; + + return colorFormErrorCount + brandingFormErrorCount; +}; + +export const getSignUpAndSignInErrorCount = ( + errors: FieldErrorsImpl>, + formData: SignInExperienceForm +) => { + const signUpIdentifier = formData.signUp?.identifier; + /** + * Note: we treat the `emailOrSms` sign-up identifier as 2 errors when it's invalid. + */ + const signUpIdentifierRelatedErrorCount = signUpIdentifier + ? signUpIdentifier === SignUpIdentifier.EmailOrSms + ? 2 + : 1 + : 0; + + const { signUp, signIn } = errors; + + const signUpErrorCount = signUp?.identifier ? signUpIdentifierRelatedErrorCount : 0; + + const signInMethodErrors = signIn?.methods; + + const signInMethodErrorCount = Array.isArray(signInMethodErrors) + ? signInMethodErrors.filter(Boolean).length + : 0; + + return signUpErrorCount + signInMethodErrorCount; +}; + +export const getOthersErrorCount = ( + errors: FieldErrorsImpl> +) => { + const { termsOfUse } = errors; + + const termsOfUseErrorCount = termsOfUse ? Object.keys(termsOfUse).length : 0; + + return termsOfUseErrorCount; +}; diff --git a/packages/console/src/pages/SignInExperience/utils/identifier.ts b/packages/console/src/pages/SignInExperience/utils/identifier.ts new file mode 100644 index 000000000..0fa1e80de --- /dev/null +++ b/packages/console/src/pages/SignInExperience/utils/identifier.ts @@ -0,0 +1,32 @@ +import type { ConnectorType } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; +import { isSameArray } from '@silverhand/essentials'; + +import { identifierRequiredConnectorMapping, signUpIdentifiersMapping } from '../constants'; +import type { SignUpIdentifier } from '../types'; + +export const isVerificationRequiredSignUpIdentifiers = (signUpIdentifier: SignUpIdentifier) => { + const identifiers = signUpIdentifiersMapping[signUpIdentifier]; + + return identifiers.includes(SignInIdentifier.Email) || identifiers.includes(SignInIdentifier.Sms); +}; + +export const mapIdentifiersToSignUpIdentifier = ( + identifiers: SignInIdentifier[] +): SignUpIdentifier => { + for (const [signUpIdentifier, mappedIdentifiers] of Object.entries(signUpIdentifiersMapping)) { + if (isSameArray(identifiers, mappedIdentifiers)) { + // eslint-disable-next-line no-restricted-syntax + return signUpIdentifier as SignUpIdentifier; + } + } + throw new Error('Invalid identifiers in the sign up settings.'); +}; + +export const getSignUpRequiredConnectorTypes = ( + signUpIdentifier: SignUpIdentifier +): ConnectorType[] => + signUpIdentifiersMapping[signUpIdentifier] + .map((identifier) => identifierRequiredConnectorMapping[identifier]) + // eslint-disable-next-line unicorn/prefer-native-coercion-functions + .filter((connectorType): connectorType is ConnectorType => Boolean(connectorType)); diff --git a/packages/console/src/pages/SignInExperience/utils/language.ts b/packages/console/src/pages/SignInExperience/utils/language.ts new file mode 100644 index 000000000..769d98902 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/utils/language.ts @@ -0,0 +1,33 @@ +import en from '@logto/phrases-ui/lib/locales/en'; +import type { Translation } from '@logto/schemas'; + +export const flattenTranslation = ( + translation: Translation, + keyPrefix = '' +): Record => + Object.keys(translation).reduce((result, key) => { + const prefix = keyPrefix ? `${keyPrefix}.` : keyPrefix; + const unwrappedKey = `${prefix}${key}`; + const unwrapped = translation[key]; + + return unwrapped === undefined + ? result + : { + ...result, + ...(typeof unwrapped === 'string' + ? { [unwrappedKey]: unwrapped } + : flattenTranslation(unwrapped, unwrappedKey)), + }; + }, {}); + +const emptyTranslation = (translation: Translation): Translation => + Object.entries(translation).reduce((result, [key, value]) => { + return typeof value === 'string' + ? { ...result, [key]: '' } + : { + ...result, + [key]: emptyTranslation(value), + }; + }, {}); + +export const createEmptyUiTranslation = () => emptyTranslation(en.translation); diff --git a/packages/console/src/pages/UserDetails/components/CreateSuccess.tsx b/packages/console/src/pages/UserDetails/components/CreateSuccess.tsx index a26fb1e89..c0c350479 100644 --- a/packages/console/src/pages/UserDetails/components/CreateSuccess.tsx +++ b/packages/console/src/pages/UserDetails/components/CreateSuccess.tsx @@ -41,7 +41,13 @@ const CreateSuccess = ({ username, password, title, onClose, passwordLabel }: Pr } return ( - + & { const UserConnectors = ({ userId, connectors, onDelete }: Props) => { const api = useApi(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { data: connectorGroups, error, mutate } = useConnectorGroups(); + const { data, error, mutate } = useSWR('/api/connectors'); const [deletingConnector, setDeletingConnector] = useState(); + const connectorGroups = conditional(data && getConnectorGroups(data)); const isLoading = !connectorGroups && !error; const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/packages/console/src/pages/UserDetails/components/UserLogs.module.scss b/packages/console/src/pages/UserDetails/components/UserLogs.module.scss new file mode 100644 index 000000000..8994f64b6 --- /dev/null +++ b/packages/console/src/pages/UserDetails/components/UserLogs.module.scss @@ -0,0 +1,7 @@ +@use '@/scss/underscore' as _; + +.logs { + flex: 1; + margin-bottom: _.unit(6); + overflow-y: auto; +} diff --git a/packages/console/src/pages/UserDetails/components/UserLogs.tsx b/packages/console/src/pages/UserDetails/components/UserLogs.tsx index 3ab72d8df..f0a233300 100644 --- a/packages/console/src/pages/UserDetails/components/UserLogs.tsx +++ b/packages/console/src/pages/UserDetails/components/UserLogs.tsx @@ -1,6 +1,6 @@ import AuditLogTable from '@/components/AuditLogTable'; -import * as styles from '../index.module.scss'; +import * as styles from './UserLogs.module.scss'; type Props = { userId: string; diff --git a/packages/console/src/pages/UserDetails/components/UserSettings.tsx b/packages/console/src/pages/UserDetails/components/UserSettings.tsx index 7e3fe4376..539851267 100644 --- a/packages/console/src/pages/UserDetails/components/UserSettings.tsx +++ b/packages/console/src/pages/UserDetails/components/UserSettings.tsx @@ -1,37 +1,26 @@ import type { User } from '@logto/schemas'; import { arbitraryObjectGuard } from '@logto/schemas'; -import type { Nullable } from '@silverhand/essentials'; import { useEffect } from 'react'; import { useForm, useController } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import Button from '@/components/Button'; import CodeEditor from '@/components/CodeEditor'; +import DetailsForm from '@/components/DetailsForm'; +import FormCard from '@/components/FormCard'; import FormField from '@/components/FormField'; import TextInput from '@/components/TextInput'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import useApi from '@/hooks/use-api'; -import * as detailsStyles from '@/scss/details.module.scss'; import { safeParseJson } from '@/utilities/json'; import { uriValidator } from '@/utilities/validator'; -import * as styles from '../index.module.scss'; +import type { UserDetailsForm } from '../types'; import UserConnectors from './UserConnectors'; -type FormData = { - primaryEmail: Nullable; - primaryPhone: Nullable; - username: Nullable; - name: Nullable; - avatar: Nullable; - roleNames: string[]; - customData: string; -}; - type Props = { userData: User; - userFormData: FormData; + userFormData: UserDetailsForm; onUserUpdated: (user?: User) => void; isDeleted: boolean; }; @@ -46,7 +35,7 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop reset, formState: { isSubmitting, errors, isDirty }, getValues, - } = useForm(); + } = useForm(); const { field: { onChange, value }, @@ -96,67 +85,67 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop }); return ( -
-
- {getValues('primaryEmail') && ( - - - - )} - {getValues('primaryPhone') && ( - - - - )} - {getValues('username') && ( - - - - )} - - - - - !value || uriValidator(value) || t('errors.invalid_uri_format'), - })} - hasError={Boolean(errors.avatar)} - errorMessage={errors.avatar?.message} - placeholder={t('user_details.field_avatar_placeholder')} - /> - - - { - onUserUpdated(); - }} - /> - - + + - - -
-
-
-
-
+ {getValues('primaryEmail') && ( + + + + )} + {getValues('primaryPhone') && ( + + + + )} + {getValues('username') && ( + + + + )} + + + + + + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + hasError={Boolean(errors.avatar)} + errorMessage={errors.avatar?.message} + placeholder={t('user_details.field_avatar_placeholder')} + /> + + + { + onUserUpdated(); + }} + /> + + + + + + - + ); }; diff --git a/packages/console/src/pages/UserDetails/index.module.scss b/packages/console/src/pages/UserDetails/index.module.scss index 09480f8ad..303234e79 100644 --- a/packages/console/src/pages/UserDetails/index.module.scss +++ b/packages/console/src/pages/UserDetails/index.module.scss @@ -2,6 +2,11 @@ .backLink { margin: _.unit(1) 0 0 _.unit(1); + user-select: none; +} + +.resourceLayout { + height: 100%; } .header { @@ -68,31 +73,6 @@ } } -.body { - > :first-child { - margin-top: 0; - } - - .form { - margin-top: _.unit(8); - flex: 1; - display: flex; - flex-direction: column; - } - - .fields { - padding-bottom: _.unit(10); - flex: 1; - } - - .textField { - @include _.form-text-field; - } - - .logs { - padding: _.unit(6) 0; - } -} .resetIcon { color: var(--color-text-secondary); diff --git a/packages/console/src/pages/UserDetails/index.tsx b/packages/console/src/pages/UserDetails/index.tsx index 8d9b12ad5..caa488945 100644 --- a/packages/console/src/pages/UserDetails/index.tsx +++ b/packages/console/src/pages/UserDetails/index.tsx @@ -16,8 +16,8 @@ import Card from '@/components/Card'; import CopyToClipboard from '@/components/CopyToClipboard'; import DeleteConfirmModal from '@/components/DeleteConfirmModal'; import DetailsSkeleton from '@/components/DetailsSkeleton'; -import LinkButton from '@/components/LinkButton'; import TabNav, { TabNavItem } from '@/components/TabNav'; +import TextLink from '@/components/TextLink'; import { generatedPasswordStorageKey } from '@/consts'; import { generateAvatarPlaceHolderById } from '@/consts/avatars'; import type { RequestError } from '@/hooks/use-api'; @@ -30,6 +30,7 @@ import ResetPasswordForm from './components/ResetPasswordForm'; import UserLogs from './components/UserLogs'; import UserSettings from './components/UserSettings'; import * as styles from './index.module.scss'; +import { userDetailsParser } from './utils'; const UserDetails = () => { const location = useLocation(); @@ -53,10 +54,7 @@ const UserDetails = () => { return; } - return { - ...data, - customData: JSON.stringify(data.customData, null, 2), - }; + return userDetailsParser.toLocalForm(data); }, [data]); const onDelete = async () => { @@ -79,13 +77,10 @@ const UserDetails = () => { }; return ( -
- } - title="user_details.back_to_users" - className={styles.backLink} - /> +
+ } className={styles.backLink}> + {t('user_details.back_to_users')} + {isLoading && } {!data && error &&
{`error occurred: ${error.body?.message ?? error.message}`}
} {userId && data && ( @@ -115,7 +110,7 @@ const UserDetails = () => { )}
User ID
- +
@@ -143,9 +138,13 @@ const UserDetails = () => { { + setIsResetPasswordFormOpen(false); + }} > {
- - - {t('general.settings_nav')} - {t('user_details.tab_logs')} - - {isLogs && } - {!isLogs && userFormData && ( - { - void mutate(user); - }} - /> - )} - + + {t('general.settings_nav')} + {t('user_details.tab_logs')} + + {isLogs && } + {!isLogs && userFormData && ( + { + void mutate(user); + }} + /> + )} )} {data && password && ( diff --git a/packages/console/src/pages/UserDetails/types.ts b/packages/console/src/pages/UserDetails/types.ts new file mode 100644 index 000000000..188b3d80a --- /dev/null +++ b/packages/console/src/pages/UserDetails/types.ts @@ -0,0 +1,9 @@ +export type UserDetailsForm = { + primaryEmail: string; + primaryPhone: string; + username: string; + name: string; + avatar: string; + roleNames: string[]; + customData: string; +}; diff --git a/packages/console/src/pages/UserDetails/utils.ts b/packages/console/src/pages/UserDetails/utils.ts new file mode 100644 index 000000000..d24bc4e83 --- /dev/null +++ b/packages/console/src/pages/UserDetails/utils.ts @@ -0,0 +1,19 @@ +import type { User } from '@logto/schemas'; + +import type { UserDetailsForm } from './types'; + +export const userDetailsParser = { + toLocalForm: (data: User): UserDetailsForm => { + const { primaryEmail, primaryPhone, username, name, avatar, roleNames, customData } = data; + + return { + primaryEmail: primaryEmail ?? '', + primaryPhone: primaryPhone ?? '', + username: username ?? '', + name: name ?? '', + avatar: avatar ?? '', + roleNames, + customData: JSON.stringify(customData, null, 2), + }; + }, +}; diff --git a/packages/console/src/pages/Users/index.module.scss b/packages/console/src/pages/Users/index.module.scss index d065e09cb..f2834ae1b 100644 --- a/packages/console/src/pages/Users/index.module.scss +++ b/packages/console/src/pages/Users/index.module.scss @@ -1,36 +1,24 @@ @use '@/scss/underscore' as _; -.card { - @include _.flex-column; -} - -.headline { - display: flex; - justify-content: space-between; -} - .filter { - margin: _.unit(4) 0; + padding: _.unit(3); + background-color: var(--color-layer-1); + border-bottom: 1px solid var(--color-divider); + border-radius: 12px 12px 0 0; } -.tableContainer { - flex: 1; +.tableLayout { + display: flex; + flex-direction: column; - >table { - >tbody { - >tr { - >td { - padding-top: 10px; - padding-bottom: 10px; - } - } - } + .tableContainer { + border-top-left-radius: 0; + border-top-right-radius: 0; } } .pagination { margin-top: _.unit(4); - min-height: 32px; } .avatar { diff --git a/packages/console/src/pages/Users/index.tsx b/packages/console/src/pages/Users/index.tsx index bf71fbda5..9ae4cc5ed 100644 --- a/packages/console/src/pages/Users/index.tsx +++ b/packages/console/src/pages/Users/index.tsx @@ -10,7 +10,6 @@ import useSWR from 'swr'; import Plus from '@/assets/images/plus.svg'; import ApplicationName from '@/components/ApplicationName'; import Button from '@/components/Button'; -import Card from '@/components/Card'; import CardTitle from '@/components/CardTitle'; import DateTime from '@/components/DateTime'; import ItemPreview from '@/components/ItemPreview'; @@ -23,6 +22,7 @@ import { generatedPasswordStorageKey } from '@/consts'; import { generateAvatarPlaceHolderById } from '@/consts/avatars'; import type { RequestError } from '@/hooks/use-api'; import * as modalStyles from '@/scss/modal.module.scss'; +import * as resourcesStyles from '@/scss/resources.module.scss'; import * as tableStyles from '@/scss/table.module.scss'; import CreateForm from './components/CreateForm'; @@ -30,6 +30,8 @@ import * as styles from './index.module.scss'; const pageSize = 20; +const userTableColumn = 3; + const Users = () => { const [isCreateFormOpen, setIsCreateFormOpen] = useState(false); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); @@ -46,8 +48,8 @@ const Users = () => { const [users, totalCount] = data ?? []; return ( - -
+
+
-
- { - setQuery(value ? { search: value } : {}); - }} - onClearSearch={() => { - setQuery({}); - }} - /> -
-
- - - - - - - - - - - - - - - {!data && error && ( - mutate(undefined, true)} - /> - )} - {isLoading && } - {users?.length === 0 && ( - - { - navigate(`/users/${id}`); - }} - > - - - - - ))} - -
{t('users.user_name')}{t('users.application_name')}{t('users.latest_sign_in')}
- - } - to={`/users/${id}`} - size="compact" - /> - {applicationId ? : '-'} - {lastSignInAt} -
-
-
- {!!totalCount && ( - { - setQuery({ page: String(page), ...conditional(keyword && { search: keyword }) }); + +
+
+ { + setQuery(value ? { search: value } : {}); + }} + onClearSearch={() => { + setQuery({}); }} /> - )} +
+
+ + + + + + + + + + + + + + + {!data && error && ( + mutate(undefined, true)} + /> + )} + {isLoading && } + {users?.length === 0 && ( + + { + navigate(`/users/${id}`); + }} + > + + + + + ))} + +
{t('users.user_name')}{t('users.application_name')}{t('users.latest_sign_in')}
+ + } + to={`/users/${id}`} + size="compact" + /> + {applicationId ? : '-'} + {lastSignInAt} +
+
- + { + setQuery({ page: String(page), ...conditional(keyword && { search: keyword }) }); + }} + /> +
); }; diff --git a/packages/console/src/scss/_underscore.scss b/packages/console/src/scss/_underscore.scss index 90c142c20..21a2853d5 100644 --- a/packages/console/src/scss/_underscore.scss +++ b/packages/console/src/scss/_underscore.scss @@ -24,7 +24,7 @@ } @mixin form-text-field { - width: dim.$form-text-field-width; + width: 100%; } @mixin vertical-bar { diff --git a/packages/console/src/scss/resources.module.scss b/packages/console/src/scss/resources.module.scss new file mode 100644 index 000000000..57d36a0d1 --- /dev/null +++ b/packages/console/src/scss/resources.module.scss @@ -0,0 +1,21 @@ +@use '@/scss/underscore' as _; + +.container { + width: 100%; + display: flex; + flex-direction: column; + padding-bottom: _.unit(6); + height: 100%; +} + +.headline { + display: flex; + justify-content: space-between; + align-items: center; +} + +.table { + flex: 1; + margin-top: _.unit(4); + overflow: hidden; +} diff --git a/packages/console/src/scss/table.module.scss b/packages/console/src/scss/table.module.scss index f16e48b3f..16f1737df 100644 --- a/packages/console/src/scss/table.module.scss +++ b/packages/console/src/scss/table.module.scss @@ -13,20 +13,29 @@ tr.clickable { } .scrollable { - overflow-y: auto; - border: 1px solid var(--color-divider); + max-height: 100%; + background-color: var(--color-layer-1); border-radius: 12px; + overflow-y: auto; + padding-bottom: _.unit(2); table { border: none; box-shadow: none; - thead tr { + thead { + background: var(--color-layer-1); position: sticky; top: 0; + } - th { - background: var(--color-layer-1); + tbody { + tr { + &:last-child { + td { + border-bottom: unset; + } + } } } } diff --git a/packages/console/src/types/connector.ts b/packages/console/src/types/connector.ts index 81a2d0101..08e8f58e0 100644 --- a/packages/console/src/types/connector.ts +++ b/packages/console/src/types/connector.ts @@ -1,10 +1,9 @@ import type { ConnectorResponse } from '@logto/schemas'; -export type ConnectorGroup = Pick< +export type ConnectorGroup = Pick< ConnectorResponse, - 'name' | 'logo' | 'logoDark' | 'target' | 'type' | 'description' + 'name' | 'logo' | 'logoDark' | 'target' | 'type' | 'description' | 'isStandard' > & { id: string; - enabled: boolean; - connectors: ConnectorResponse[]; + connectors: T[]; }; diff --git a/packages/console/src/types/guide.ts b/packages/console/src/types/guide.ts index 10418f263..9a22384dc 100644 --- a/packages/console/src/types/guide.ts +++ b/packages/console/src/types/guide.ts @@ -1,5 +1,4 @@ export type GuideForm = { redirectUris: string[]; postLogoutRedirectUris: string[]; - connectorConfigJson: string; }; diff --git a/packages/console/src/utilities/a11y.ts b/packages/console/src/utilities/a11y.ts index e46b51f8b..6f65b62c1 100644 --- a/packages/console/src/utilities/a11y.ts +++ b/packages/console/src/utilities/a11y.ts @@ -15,7 +15,11 @@ export const onKeyDownHandler = } if (typeof callback === 'object') { - callback[key]?.(event); - event.preventDefault(); + const handler = callback[key]; + + if (handler) { + handler(event); + event.preventDefault(); + } } }; diff --git a/packages/console/svgo.config.js b/packages/console/svgo.config.js deleted file mode 100644 index 842fe3084..000000000 --- a/packages/console/svgo.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - plugins: [ - { - name: 'preset-default', - params: { - overrides: { - cleanupIDs: false, - removeViewBox: false, - }, - }, - }, - ], -}; diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js new file mode 100644 index 000000000..fff6b08fe --- /dev/null +++ b/packages/core/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('jest').Config} */ +const config = { + coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'], + coverageReporters: ['text-summary', 'lcov'], + testPathIgnorePatterns: ['/node_modules/', '/build/routes/session/'], // `routes/session` is freezed + setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.js'], + roots: ['./build'], + moduleNameMapper: { + '^#src/(.*)\\.js(x)?$': '/build/$1', + '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + }, +}; + +export default config; diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts deleted file mode 100644 index 0a8c2a835..000000000 --- a/packages/core/jest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { merge, Config } from '@silverhand/jest-config'; - -const config: Config.InitialOptions = merge({ - testPathIgnorePatterns: ['/core/connectors/'], - setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.ts'], -}); - -export default config; diff --git a/packages/core/jest.setup.js b/packages/core/jest.setup.js new file mode 100644 index 000000000..deb0c995e --- /dev/null +++ b/packages/core/jest.setup.js @@ -0,0 +1,39 @@ +/** + * Setup environment variables for unit test + */ + +import { mockEsm } from '@logto/shared/esm'; +import { createMockQueryResult, createMockPool } from 'slonik'; + +const { jest } = import.meta; + +mockEsm('#src/env-set/index.js', () => ({ + MountedApps: { + Api: 'api', + Oidc: 'oidc', + Console: 'console', + DemoApp: 'demo-app', + Welcome: 'welcome', + }, + default: { + get values() { + return { + endpoint: 'https://logto.test', + adminConsoleUrl: 'https://logto.test/console', + }; + }, + get oidc() { + return { + issuer: 'https://logto.test/oidc', + }; + }, + get pool() { + return createMockPool({ query: async () => createMockQueryResult([]) }); + }, + load: jest.fn(), + }, +})); + +// Logger is not considered in all test cases +// eslint-disable-next-line unicorn/consistent-function-scoping +mockEsm('koa-logger', () => ({ default: () => (_, next) => next() })); diff --git a/packages/core/jest.setup.ts b/packages/core/jest.setup.ts deleted file mode 100644 index fc6b478c4..000000000 --- a/packages/core/jest.setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Setup environment variables for unit test - */ - -import envSet from '@/env-set'; - -jest.mock('@/lib/logto-config'); -jest.mock('@/env-set/check-alteration-state'); - -// eslint-disable-next-line unicorn/prefer-top-level-await -(async () => { - await envSet.load(); -})(); diff --git a/packages/core/package.json b/packages/core/package.json index f1b68fda1..704a2c7d3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,30 +5,36 @@ "main": "build/index.js", "author": "Silverhand Inc. ", "license": "MPL-2.0", + "type": "module", "private": true, + "imports": { + "#src/*": "./build/*" + }, "scripts": { "precommit": "lint-staged", "copyfiles": "copyfiles -u 1 src/**/*.md build", "build": "rm -rf build/ && tsc -p tsconfig.build.json && pnpm run copyfiles", + "build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "dev": "rm -rf build/ && pnpm run copyfiles && nodemon", "start": "NODE_ENV=production node build/index.js", - "test": "jest", - "test:ci": "jest --coverage --silent", + "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "pnpm build:test && pnpm test:only", + "test:ci": "pnpm run test:only --coverage --silent", "test:report": "codecov -F core" }, "dependencies": { - "@logto/cli": "workspace:^", - "@logto/connector-kit": "1.0.0-beta.27", - "@logto/core-kit": "^1.0.0-beta.18", - "@logto/language-kit": "1.0.0-beta.20", - "@logto/phrases": "workspace:^", - "@logto/phrases-ui": "workspace:^", - "@logto/schemas": "workspace:^", - "@logto/shared": "workspace:^", + "@logto/cli": "workspace:*", + "@logto/connector-kit": "1.0.0-beta.28", + "@logto/core-kit": "1.0.0-beta.28", + "@logto/language-kit": "1.0.0-beta.28", + "@logto/phrases": "workspace:*", + "@logto/phrases-ui": "workspace:*", + "@logto/schemas": "workspace:*", + "@logto/shared": "workspace:*", "@silverhand/essentials": "^1.3.0", - "chalk": "^4", + "chalk": "^5.0.0", "clean-deep": "^3.4.0", "date-fns": "^2.29.3", "debug": "^4.3.4", @@ -38,11 +44,10 @@ "etag": "^1.8.1", "find-up": "^5.0.0", "fs-extra": "^10.1.0", - "got": "^11.8.5", "hash-wasm": "^4.9.0", "i18next": "^21.8.16", "iconv-lite": "0.6.3", - "jose": "^4.0.0", + "jose": "^4.11.0", "js-yaml": "^4.1.0", "koa": "^2.13.1", "koa-body": "^5.0.0", @@ -53,8 +58,7 @@ "koa-router": "^12.0.0", "koa-send": "^5.0.1", "lodash.pick": "^4.4.0", - "module-alias": "^2.2.2", - "nanoid": "^3.1.23", + "nanoid": "^3.3.4", "oidc-provider": "^7.13.0", "p-retry": "^4.6.1", "query-string": "^7.0.1", @@ -63,13 +67,11 @@ "slonik-interceptor-preset": "^1.2.10", "slonik-sql-tag-raw": "^1.1.4", "snake-case": "^3.0.4", - "snakecase-keys": "^5.1.0", + "snakecase-keys": "^5.4.4", "zod": "^3.19.1" }, "devDependencies": { - "@shopify/jest-koa-mocks": "^5.0.0", "@silverhand/eslint-config": "1.3.0", - "@silverhand/jest-config": "1.2.2", "@silverhand/ts-config": "1.2.1", "@types/debug": "^4.1.7", "@types/etag": "^1.8.1", @@ -85,6 +87,7 @@ "@types/lodash.pick": "^4.4.6", "@types/node": "^16.0.0", "@types/oidc-provider": "^7.12.0", + "@types/sinon": "^10.0.13", "@types/supertest": "^2.0.11", "copyfiles": "^2.4.1", "eslint": "^8.21.0", @@ -92,19 +95,17 @@ "jest": "^29.1.2", "jest-matcher-specific-error": "^1.0.0", "lint-staged": "^13.0.0", - "nock": "^13.2.2", + "node-mocks-http": "^1.12.1", "nodemon": "^2.0.19", "openapi-types": "^12.0.0", "prettier": "^2.7.1", + "sinon": "^15.0.0", "supertest": "^6.2.2", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "engines": { "node": "^16.13.0 || ^18.12.0" }, - "_moduleAliases": { - "@": "./build" - }, "eslintConfig": { "extends": "@silverhand" }, diff --git a/packages/core/src/__mocks__/connector-base-data.ts b/packages/core/src/__mocks__/connector-base-data.ts index 71e294abd..85911a7a3 100644 --- a/packages/core/src/__mocks__/connector-base-data.ts +++ b/packages/core/src/__mocks__/connector-base-data.ts @@ -77,49 +77,63 @@ export const mockMetadata6: ConnectorMetadata = { export const mockConnector0: Connector = { id: 'id0', - enabled: true, config: {}, createdAt: 1_234_567_890_123, + syncProfile: false, + metadata: {}, + connectorId: 'id0', }; export const mockConnector1: Connector = { id: 'id1', - enabled: true, config: {}, createdAt: 1_234_567_890_234, + syncProfile: false, + metadata: {}, + connectorId: 'id1', }; export const mockConnector2: Connector = { id: 'id2', - enabled: true, config: {}, createdAt: 1_234_567_890_345, + syncProfile: false, + metadata: {}, + connectorId: 'id2', }; export const mockConnector3: Connector = { id: 'id3', - enabled: true, config: {}, createdAt: 1_234_567_890_456, + syncProfile: false, + metadata: {}, + connectorId: 'id3', }; export const mockConnector4: Connector = { id: 'id4', - enabled: true, config: {}, createdAt: 1_234_567_890_567, + syncProfile: false, + metadata: {}, + connectorId: 'id4', }; export const mockConnector5: Connector = { id: 'id5', - enabled: true, config: {}, createdAt: 1_234_567_890_567, + syncProfile: false, + metadata: {}, + connectorId: 'id5', }; export const mockConnector6: Connector = { id: 'id6', - enabled: true, config: {}, createdAt: 1_234_567_890_567, + syncProfile: false, + metadata: {}, + connectorId: 'id6', }; diff --git a/packages/core/src/__mocks__/connector.ts b/packages/core/src/__mocks__/connector.ts index 84181b3f9..db3db5267 100644 --- a/packages/core/src/__mocks__/connector.ts +++ b/packages/core/src/__mocks__/connector.ts @@ -3,7 +3,7 @@ import type { Connector } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas'; import { any } from 'zod'; -import type { LogtoConnector } from '@/connectors/types'; +import type { LogtoConnector, ConnectorFactory } from '#src/connectors/types.js'; import { mockConnector0, @@ -21,15 +21,27 @@ import { mockMetadata4, mockMetadata5, mockMetadata6, -} from './connector-base-data'; +} from './connector-base-data.js'; -export { mockMetadata } from './connector-base-data'; +export { + mockConnector0, + mockConnector1, + mockMetadata, + mockMetadata0, + mockMetadata1, + mockMetadata2, + mockMetadata3, +} from './connector-base-data.js'; + +const { jest } = import.meta; export const mockConnector: Connector = { id: 'id', - enabled: true, config: {}, createdAt: 1_234_567_890_123, + syncProfile: false, + metadata: {}, + connectorId: 'id', }; export const mockLogtoConnector = { @@ -40,6 +52,13 @@ export const mockLogtoConnector = { configGuard: any(), }; +export const mockConnectorFactory: ConnectorFactory = { + metadata: mockMetadata, + type: ConnectorType.Social, + path: 'random_path', + createConnector: jest.fn(), +}; + export const mockConnectorList: Connector[] = [ mockConnector0, mockConnector1, @@ -189,7 +208,6 @@ export const mockGoogleConnector: LogtoConnector = { dbEntry: { ...mockConnector, id: 'google', - enabled: false, }, metadata: { ...mockMetadata, @@ -211,21 +229,22 @@ export const mockLogtoConnectors = [ mockWechatNativeConnector, ]; -export const disabledSocialTarget01 = 'disableSocialTarget-id01'; -export const disabledSocialTarget02 = 'disableSocialTarget-id02'; -export const enabledSocialTarget01 = 'enabledSocialTarget-id01'; +export const socialTarget01 = 'socialTarget-id01'; +export const socialTarget02 = 'socialTarget-id02'; export const mockSocialConnectors: LogtoConnector[] = [ { dbEntry: { id: 'id0', - enabled: false, config: {}, createdAt: 1_234_567_890_123, + syncProfile: false, + metadata: {}, + connectorId: 'id0', }, metadata: { ...mockMetadata, - target: disabledSocialTarget01, + target: socialTarget01, }, type: ConnectorType.Social, ...mockLogtoConnector, @@ -233,27 +252,15 @@ export const mockSocialConnectors: LogtoConnector[] = [ { dbEntry: { id: 'id1', - enabled: true, config: {}, createdAt: 1_234_567_890_123, + syncProfile: false, + metadata: {}, + connectorId: 'id1', }, metadata: { ...mockMetadata, - target: enabledSocialTarget01, - }, - type: ConnectorType.Social, - ...mockLogtoConnector, - }, - { - dbEntry: { - id: 'id2', - enabled: false, - config: {}, - createdAt: 1_234_567_890_123, - }, - metadata: { - ...mockMetadata, - target: disabledSocialTarget02, + target: socialTarget02, }, type: ConnectorType.Social, ...mockLogtoConnector, diff --git a/packages/core/src/__mocks__/custom-phrase.ts b/packages/core/src/__mocks__/custom-phrase.ts index 3caf90339..6c2c848b5 100644 --- a/packages/core/src/__mocks__/custom-phrase.ts +++ b/packages/core/src/__mocks__/custom-phrase.ts @@ -1,4 +1,4 @@ -import en from '@logto/phrases-ui/lib/locales/en'; +import en from '@logto/phrases-ui/lib/locales/en.js'; export const enTag = 'en'; export const trTrTag = 'tr-TR'; diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index 4f6865581..552ac3fc1 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -1,9 +1,9 @@ import type { Application, Passcode, Resource, Role, Setting } from '@logto/schemas'; import { ApplicationType, PasscodeType } from '@logto/schemas'; -export * from './connector'; -export * from './sign-in-experience'; -export * from './user'; +export * from './connector.js'; +export * from './sign-in-experience.js'; +export * from './user.js'; export const mockApplication: Application = { id: 'foo', @@ -32,6 +32,7 @@ export const mockResource: Resource = { }; export const mockRole: Role = { + id: 'role_id', name: 'admin', description: 'admin', }; diff --git a/packages/core/src/__mocks__/interactions.ts b/packages/core/src/__mocks__/interactions.ts new file mode 100644 index 000000000..bfb3196dd --- /dev/null +++ b/packages/core/src/__mocks__/interactions.ts @@ -0,0 +1,26 @@ +export const interactionMocks = [ + { + username: 'username', + password: 'password', + }, + { + email: 'email', + password: 'password', + }, + { + phone: 'phone', + password: 'password', + }, + { + email: 'email@logto.io', + passcode: 'passcode', + }, + { + phone: '123456', + passcode: 'passcode', + }, + { + connectorId: 'connectorId', + connectorData: { code: 'code' }, + }, +]; diff --git a/packages/core/src/__mocks__/sign-in-experience.ts b/packages/core/src/__mocks__/sign-in-experience.ts index 287c2207e..6b9a702a4 100644 --- a/packages/core/src/__mocks__/sign-in-experience.ts +++ b/packages/core/src/__mocks__/sign-in-experience.ts @@ -7,7 +7,7 @@ import type { SignUp, SignIn, } from '@logto/schemas'; -import { BrandingStyle, SignInMode, SignUpIdentifier, SignInIdentifier } from '@logto/schemas'; +import { BrandingStyle, SignInMode, SignInIdentifier } from '@logto/schemas'; export const mockSignInExperience: SignInExperience = { id: 'foo', @@ -29,7 +29,7 @@ export const mockSignInExperience: SignInExperience = { fallbackLanguage: 'en', }, signUp: { - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], password: true, verify: false, }, @@ -82,7 +82,7 @@ export const mockLanguageInfo: LanguageInfo = { }; export const mockSignUp: SignUp = { - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], password: true, verify: false, }; diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index ebdcae0b8..0ea5765f2 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -1,46 +1,39 @@ +import { mockEsmDefault, pickDefault } from '@logto/shared/esm'; import Koa from 'koa'; -import * as koaErrorHandler from '@/middleware/koa-error-handler'; -import * as koaI18next from '@/middleware/koa-i18next'; -import * as koaLog from '@/middleware/koa-log'; -import * as koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; -import * as koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler'; -import * as koaSpaProxy from '@/middleware/koa-spa-proxy'; -import * as initOidc from '@/oidc/init'; -import * as initRouter from '@/routes/init'; +import { emptyMiddleware } from '#src/utils/test-utils.js'; -import initI18n from '../i18n/init'; -import initApp from './init'; +const { jest } = import.meta; + +const middlewareList = [ + 'error-handler', + 'i18next', + 'log', + 'oidc-error-handler', + 'slonik-error-handler', + 'spa-proxy', +].map((name) => { + const mock = jest.fn(() => emptyMiddleware); + mockEsmDefault(`#src/middleware/koa-${name}.js`, () => mock); + + return mock; +}); + +const initI18n = await pickDefault(import('../i18n/init.js')); +const initApp = await pickDefault(import('./init.js')); describe('App Init', () => { const listenMock = jest.spyOn(Koa.prototype, 'listen').mockImplementation(jest.fn()); - const middlewareList = [ - koaErrorHandler, - koaI18next, - koaLog, - koaOIDCErrorHandler, - koaSlonikErrorHandler, - koaSpaProxy, - ]; - const initMethods = [initRouter, initOidc]; - - const middlewareSpys = middlewareList.map((module) => jest.spyOn(module, 'default')); - const initMethodSpys = initMethods.map((module) => jest.spyOn(module, 'default')); - it('app init properly with 404 not found route', async () => { const app = new Koa(); await initI18n(); await initApp(app); - for (const middleware of middlewareSpys) { + for (const middleware of middlewareList) { expect(middleware).toBeCalled(); } - for (const inits of initMethodSpys) { - expect(inits).toBeCalled(); - } - expect(listenMock).toBeCalled(); }); }); diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index c98a8b848..348b6c85f 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -1,31 +1,32 @@ import fs from 'fs/promises'; import https from 'https'; +import { deduplicate } from '@silverhand/essentials'; import chalk from 'chalk'; import type Koa from 'koa'; import compose from 'koa-compose'; import koaLogger from 'koa-logger'; import mount from 'koa-mount'; -import envSet, { MountedApps } from '@/env-set'; -import koaCheckDemoApp from '@/middleware/koa-check-demo-app'; -import koaConnectorErrorHandler from '@/middleware/koa-connector-error-handler'; -import koaErrorHandler from '@/middleware/koa-error-handler'; -import koaI18next from '@/middleware/koa-i18next'; -import koaLog from '@/middleware/koa-log'; -import koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; -import koaRootProxy from '@/middleware/koa-root-proxy'; -import koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler'; -import koaSpaProxy from '@/middleware/koa-spa-proxy'; -import koaSpaSessionGuard from '@/middleware/koa-spa-session-guard'; -import koaWelcomeProxy from '@/middleware/koa-welcome-proxy'; -import initOidc from '@/oidc/init'; -import initRouter from '@/routes/init'; +import envSet, { MountedApps } from '#src/env-set/index.js'; +import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js'; +import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js'; +import koaErrorHandler from '#src/middleware/koa-error-handler.js'; +import koaI18next from '#src/middleware/koa-i18next.js'; +import koaLog from '#src/middleware/koa-log.js'; +import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js'; +import koaRootProxy from '#src/middleware/koa-root-proxy.js'; +import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js'; +import koaSpaProxy from '#src/middleware/koa-spa-proxy.js'; +import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js'; +import koaWelcomeProxy from '#src/middleware/koa-welcome-proxy.js'; +import initOidc from '#src/oidc/init.js'; +import initRouter from '#src/routes/init.js'; const logListening = () => { const { localhostUrl, endpoint } = envSet.values; - for (const url of new Set([localhostUrl, endpoint])) { + for (const url of deduplicate([localhostUrl, endpoint])) { console.log(chalk.bold(chalk.green(`App is running at ${url}`))); } }; diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts index 8ae81e02f..8729f0126 100644 --- a/packages/core/src/connectors/index.ts +++ b/packages/core/src/connectors/index.ts @@ -1,31 +1,33 @@ import { existsSync } from 'fs'; +import { fileURLToPath } from 'node:url'; import path from 'path'; -import { connectorDirectory } from '@logto/cli/lib/constants'; -import { getConnectorPackagesFromDirectory } from '@logto/cli/lib/utilities'; +import { connectorDirectory } from '@logto/cli/lib/constants.js'; +import { getConnectorPackagesFromDirectory } from '@logto/cli/lib/utilities.js'; import type { AllConnector, CreateConnector } from '@logto/connector-kit'; import { validateConfig } from '@logto/connector-kit'; import { findPackage } from '@logto/shared'; import chalk from 'chalk'; -import RequestError from '@/errors/RequestError'; -import { findAllConnectors, insertConnector } from '@/queries/connector'; +import RequestError from '#src/errors/RequestError/index.js'; +import { findAllConnectors } from '#src/queries/connector.js'; -import { defaultConnectorMethods } from './consts'; -import type { LoadConnector, LogtoConnector } from './types'; -import { getConnectorConfig, readUrl, validateConnectorModule } from './utilities'; +import { defaultConnectorMethods } from './consts.js'; +import { metaUrl } from './meta-url.js'; +import type { ConnectorFactory, LogtoConnector } from './types.js'; +import { getConnectorConfig, parseMetadata, validateConnectorModule } from './utilities/index.js'; + +const currentDirname = path.dirname(fileURLToPath(metaUrl)); // eslint-disable-next-line @silverhand/fp/no-let -let cachedConnectors: LoadConnector[] | undefined; +let cachedConnectorFactories: ConnectorFactory[] | undefined; -const loadConnectors = async () => { - if (cachedConnectors) { - return cachedConnectors; +export const loadConnectorFactories = async () => { + if (cachedConnectorFactories) { + return cachedConnectorFactories; } - // Until we migrate to ESM - // eslint-disable-next-line unicorn/prefer-module - const coreDirectory = await findPackage(__dirname); + const coreDirectory = await findPackage(currentDirname); const directory = coreDirectory && path.join(coreDirectory, connectorDirectory); if (!directory || !existsSync(directory)) { @@ -34,38 +36,27 @@ const loadConnectors = async () => { const connectorPackages = await getConnectorPackagesFromDirectory(directory); - const connectors = await Promise.all( + const connectorFactories = await Promise.all( connectorPackages.map(async ({ path: packagePath, name }) => { try { - // eslint-disable-next-line no-restricted-syntax - const { default: createConnector } = (await import(packagePath)) as { - default: CreateConnector; + // TODO: fix type and remove `/lib/index.js` suffix once we upgrade all connectors to ESM + const { + default: { default: createConnector }, + // eslint-disable-next-line no-restricted-syntax + } = (await import(packagePath + '/lib/index.js')) as { + default: { + default: CreateConnector; + }; }; const rawConnector = await createConnector({ getConfig: getConnectorConfig }); validateConnectorModule(rawConnector); - const connector: LoadConnector = { - ...defaultConnectorMethods, - ...rawConnector, - metadata: { - ...rawConnector.metadata, - logo: await readUrl(rawConnector.metadata.logo, packagePath, 'svg'), - logoDark: - rawConnector.metadata.logoDark && - (await readUrl(rawConnector.metadata.logoDark, packagePath, 'svg')), - readme: await readUrl(rawConnector.metadata.readme, packagePath, 'text'), - configTemplate: await readUrl( - rawConnector.metadata.configTemplate, - packagePath, - 'text' - ), - }, - validateConfig: (config: unknown) => { - validateConfig(config, rawConnector.configGuard); - }, + return { + metadata: await parseMetadata(rawConnector.metadata, packagePath), + type: rawConnector.type, + createConnector, + path: packagePath, }; - - return connector; } catch (error: unknown) { if (error instanceof Error) { console.log( @@ -83,32 +74,63 @@ const loadConnectors = async () => { ); // eslint-disable-next-line @silverhand/fp/no-mutation - cachedConnectors = connectors.filter( - (connector): connector is LoadConnector => connector !== undefined + cachedConnectorFactories = connectorFactories.filter( + (connectorFactory): connectorFactory is ConnectorFactory => connectorFactory !== undefined ); - return cachedConnectors; + return cachedConnectorFactories; }; export const getLogtoConnectors = async (): Promise => { - const connectors = await findAllConnectors(); - const connectorMap = new Map(connectors.map((connector) => [connector.id, connector])); + const databaseConnectors = await findAllConnectors(); - const logtoConnectors = await loadConnectors(); + const logtoConnectors = await Promise.all( + databaseConnectors.map(async (databaseConnector) => { + const { id, metadata, connectorId } = databaseConnector; - return logtoConnectors.map((element) => { - const { id } = element.metadata; - const connector = connectorMap.get(id); + const connectorFactories = await loadConnectorFactories(); + const connectorFactory = connectorFactories.find( + ({ metadata }) => metadata.id === connectorId + ); - if (!connector) { - throw new RequestError({ code: 'entity.not_found', id, status: 404 }); - } + if (!connectorFactory) { + return; + } - return { - ...element, - dbEntry: connector, - }; - }); + const { createConnector, path: packagePath } = connectorFactory; + + try { + const rawConnector = await createConnector({ + getConfig: async () => { + return getConnectorConfig(id); + }, + }); + validateConnectorModule(rawConnector); + const rawMetadata = await parseMetadata(rawConnector.metadata, packagePath); + + const connector: AllConnector = { + ...defaultConnectorMethods, + ...rawConnector, + metadata: { + ...rawMetadata, + ...metadata, + }, + }; + + return { + ...connector, + validateConfig: (config: unknown) => { + validateConfig(config, rawConnector.configGuard); + }, + dbEntry: databaseConnector, + }; + } catch {} + }) + ); + + return logtoConnectors.filter( + (logtoConnector): logtoConnector is LogtoConnector => logtoConnector !== undefined + ); }; export const getLogtoConnectorById = async (id: string): Promise => { @@ -125,26 +147,3 @@ export const getLogtoConnectorById = async (id: string): Promise return pickedConnector; }; - -export const initConnectors = async () => { - const connectors = await findAllConnectors(); - const existingConnectors = new Map(connectors.map((connector) => [connector.id, connector])); - const allConnectors = await loadConnectors(); - const newConnectors = allConnectors.filter(({ metadata: { id } }) => { - const connector = existingConnectors.get(id); - - if (!connector) { - return true; - } - - return connector.config === JSON.stringify({}); - }); - - await Promise.all( - newConnectors.map(async ({ metadata: { id } }) => { - await insertConnector({ - id, - }); - }) - ); -}; diff --git a/packages/core/src/connectors/meta-url.ts b/packages/core/src/connectors/meta-url.ts new file mode 100644 index 000000000..740bd697b --- /dev/null +++ b/packages/core/src/connectors/meta-url.ts @@ -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; diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts index 95412a334..aa3714a28 100644 --- a/packages/core/src/connectors/types.ts +++ b/packages/core/src/connectors/types.ts @@ -1,4 +1,4 @@ -import type { AllConnector } from '@logto/connector-kit'; +import type { AllConnector, CreateConnector } from '@logto/connector-kit'; import type { Connector, PasscodeType } from '@logto/schemas'; import { z } from 'zod'; @@ -19,13 +19,17 @@ export type SocialUserInfo = z.infer; /** * Dynamic loaded connector type. */ -export type LoadConnector = T & { - validateConfig: (config: unknown) => void; +export type ConnectorFactory = Pick< + T, + 'type' | 'metadata' +> & { + createConnector: CreateConnector; + path: string; }; /** * The connector type with full context. */ -export type LogtoConnector = LoadConnector & { - dbEntry: Connector; -}; +export type LogtoConnector = T & { + validateConfig: (config: unknown) => void; +} & { dbEntry: Connector }; diff --git a/packages/core/src/connectors/utilities/index.test.ts b/packages/core/src/connectors/utilities/index.test.ts index aaad17d9e..25b409f23 100644 --- a/packages/core/src/connectors/utilities/index.test.ts +++ b/packages/core/src/connectors/utilities/index.test.ts @@ -1,25 +1,27 @@ import type { Connector } from '@logto/schemas'; +import { mockEsmWithActual } from '@logto/shared/esm'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; -import { getConnectorConfig } from '.'; +const { jest } = import.meta; const connectors: Connector[] = [ { id: 'id', - enabled: true, config: { foo: 'bar' }, createdAt: 0, + syncProfile: false, + connectorId: 'id', + metadata: {}, }, ]; -const findAllConnectors = jest.fn(async () => connectors); - -jest.mock('@/queries/connector', () => ({ - ...jest.requireActual('@/queries/connector'), - findAllConnectors: async () => findAllConnectors(), +await mockEsmWithActual('#src/queries/connector.js', () => ({ + findAllConnectors: jest.fn(async () => connectors), })); +const { getConnectorConfig } = await import('./index.js'); + it('getConnectorConfig() should return right config', async () => { const config = await getConnectorConfig('id'); expect(config).toMatchObject({ foo: 'bar' }); diff --git a/packages/core/src/connectors/utilities/index.ts b/packages/core/src/connectors/utilities/index.ts index a8e66edda..f8675baca 100644 --- a/packages/core/src/connectors/utilities/index.ts +++ b/packages/core/src/connectors/utilities/index.ts @@ -2,12 +2,12 @@ import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import path from 'path'; -import type { BaseConnector } from '@logto/connector-kit'; +import type { AllConnector, BaseConnector } from '@logto/connector-kit'; import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit'; -import RequestError from '@/errors/RequestError'; -import { findAllConnectors } from '@/queries/connector'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { findAllConnectors } from '#src/queries/connector.js'; +import assertThat from '#src/utils/assert-that.js'; export const getConnectorConfig = async (id: string): Promise => { const connectors = await findAllConnectors(); @@ -59,3 +59,13 @@ export const readUrl = async ( return readFile(path.join(baseUrl, url), 'utf8'); }; + +export const parseMetadata = async (metadata: AllConnector['metadata'], packagePath: string) => { + return { + ...metadata, + logo: await readUrl(metadata.logo, packagePath, 'svg'), + logoDark: metadata.logoDark && (await readUrl(metadata.logoDark, packagePath, 'svg')), + readme: await readUrl(metadata.readme, packagePath, 'text'), + configTemplate: await readUrl(metadata.configTemplate, packagePath, 'text'), + }; +}; diff --git a/packages/core/src/database/find-entity-by-id.ts b/packages/core/src/database/find-entity-by-id.ts index 25a9f8927..f65e45d8d 100644 --- a/packages/core/src/database/find-entity-by-id.ts +++ b/packages/core/src/database/find-entity-by-id.ts @@ -2,10 +2,10 @@ import type { SchemaLike, GeneratedSchema } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { sql, NotFoundError } from 'slonik'; -import envSet from '@/env-set'; -import RequestError from '@/errors/RequestError'; -import assertThat from '@/utils/assert-that'; -import { isKeyOf } from '@/utils/schema'; +import envSet from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; +import { isKeyOf } from '#src/utils/schema.js'; export const buildFindEntityById = ( schema: GeneratedSchema diff --git a/packages/core/src/database/insert-into.test.ts b/packages/core/src/database/insert-into.test.ts index 4622dc807..7585eb90b 100644 --- a/packages/core/src/database/insert-into.test.ts +++ b/packages/core/src/database/insert-into.test.ts @@ -3,12 +3,13 @@ import { Users } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import decamelize from 'decamelize'; -import envSet from '@/env-set'; -import { InsertionError } from '@/errors/SlonikError'; -import { createTestPool } from '@/utils/test-utils'; +import envSet from '#src/env-set/index.js'; +import { InsertionError } from '#src/errors/SlonikError/index.js'; +import { createTestPool } from '#src/utils/test-utils.js'; -import { buildInsertInto } from './insert-into'; +import { buildInsertInto } from './insert-into.js'; +const { jest } = import.meta; const poolSpy = jest.spyOn(envSet, 'pool', 'get'); const buildExpectedInsertIntoSql = (keys: string[]) => [ diff --git a/packages/core/src/database/insert-into.ts b/packages/core/src/database/insert-into.ts index b2604d486..404a7b378 100644 --- a/packages/core/src/database/insert-into.ts +++ b/packages/core/src/database/insert-into.ts @@ -10,9 +10,9 @@ import { has } from '@silverhand/essentials'; import type { IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; -import envSet from '@/env-set'; -import { InsertionError } from '@/errors/SlonikError'; -import assertThat from '@/utils/assert-that'; +import envSet from '#src/env-set/index.js'; +import { InsertionError } from '#src/errors/SlonikError/index.js'; +import assertThat from '#src/utils/assert-that.js'; const setExcluded = (...fields: IdentifierSqlToken[]) => sql.join( diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts index 5a1b40c27..64d3e04a7 100644 --- a/packages/core/src/database/row-count.ts +++ b/packages/core/src/database/row-count.ts @@ -1,7 +1,7 @@ import type { IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; -import envSet from '@/env-set'; +import envSet from '#src/env-set/index.js'; export const getTotalRowCount = async (table: IdentifierSqlToken) => envSet.pool.one<{ count: number }>(sql` diff --git a/packages/core/src/database/update-where.test.ts b/packages/core/src/database/update-where.test.ts index ff5bae6b9..0595bf0c1 100644 --- a/packages/core/src/database/update-where.test.ts +++ b/packages/core/src/database/update-where.test.ts @@ -2,12 +2,13 @@ import type { CreateUser, User } from '@logto/schemas'; import { Users, Applications } from '@logto/schemas'; import type { UpdateWhereData } from '@logto/shared'; -import envSet from '@/env-set'; -import { UpdateError } from '@/errors/SlonikError'; -import { createTestPool } from '@/utils/test-utils'; +import envSet from '#src/env-set/index.js'; +import { UpdateError } from '#src/errors/SlonikError/index.js'; +import { createTestPool } from '#src/utils/test-utils.js'; -import { buildUpdateWhere } from './update-where'; +import { buildUpdateWhere } from './update-where.js'; +const { jest } = import.meta; const poolSpy = jest.spyOn(envSet, 'pool', 'get'); describe('buildUpdateWhere()', () => { diff --git a/packages/core/src/database/update-where.ts b/packages/core/src/database/update-where.ts index 62f385900..99d3e74b4 100644 --- a/packages/core/src/database/update-where.ts +++ b/packages/core/src/database/update-where.ts @@ -1,14 +1,14 @@ import type { SchemaLike, GeneratedSchema } from '@logto/schemas'; -import type { UpdateWhereData } from '@logto/shared'; import { convertToIdentifiers, convertToPrimitiveOrSql, conditionalSql } from '@logto/shared'; +import type { UpdateWhereData } from '@logto/shared'; import type { Truthy } from '@silverhand/essentials'; import { notFalsy } from '@silverhand/essentials'; import { sql } from 'slonik'; -import envSet from '@/env-set'; -import { UpdateError } from '@/errors/SlonikError'; -import assertThat from '@/utils/assert-that'; -import { isKeyOf } from '@/utils/schema'; +import envSet from '#src/env-set/index.js'; +import { UpdateError } from '#src/errors/SlonikError/index.js'; +import assertThat from '#src/utils/assert-that.js'; +import { isKeyOf } from '#src/utils/schema.js'; type BuildUpdateWhere = { ( diff --git a/packages/core/src/env-set/check-alteration-state.ts b/packages/core/src/env-set/check-alteration-state.ts index 3314af97f..79dda2e67 100644 --- a/packages/core/src/env-set/check-alteration-state.ts +++ b/packages/core/src/env-set/check-alteration-state.ts @@ -1,4 +1,4 @@ -import { getUndeployedAlterations } from '@logto/cli/lib/commands/database/alteration'; +import { getUndeployedAlterations } from '@logto/cli/lib/commands/database/alteration/index.js'; import chalk from 'chalk'; import type { DatabasePool } from 'slonik'; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 862afecb6..34dc0360d 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -2,13 +2,13 @@ import type { Optional } from '@silverhand/essentials'; import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; import type { DatabasePool } from 'slonik'; -import { getOidcConfigs } from '@/lib/logto-config'; -import { appendPath } from '@/utils/url'; +import { getOidcConfigs } from '#src/lib/logto-config.js'; +import { appendPath } from '#src/utils/url.js'; -import { checkAlterationState } from './check-alteration-state'; -import createPoolByEnv from './create-pool-by-env'; -import loadOidcValues from './oidc'; -import { isTrue } from './parameters'; +import { checkAlterationState } from './check-alteration-state.js'; +import createPoolByEnv from './create-pool-by-env.js'; +import loadOidcValues from './oidc.js'; +import { isTrue } from './parameters.js'; export enum MountedApps { Api = 'api', diff --git a/packages/core/src/env-set/oidc.ts b/packages/core/src/env-set/oidc.ts index 1df120cec..4118177f7 100644 --- a/packages/core/src/env-set/oidc.ts +++ b/packages/core/src/env-set/oidc.ts @@ -4,7 +4,7 @@ import type { LogtoOidcConfigType } from '@logto/schemas'; import { LogtoOidcConfigKey } from '@logto/schemas'; import { createLocalJWKSet } from 'jose'; -import { exportJWK } from '@/utils/jwks'; +import { exportJWK } from '#src/utils/jwks.js'; const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => { const cookieKeys = configs[LogtoOidcConfigKey.CookieKeys]; diff --git a/packages/core/src/errors/RequestError/request-error.test.ts b/packages/core/src/errors/RequestError/request-error.test.ts index b23533d01..76d2f763c 100644 --- a/packages/core/src/errors/RequestError/request-error.test.ts +++ b/packages/core/src/errors/RequestError/request-error.test.ts @@ -1,9 +1,9 @@ import type { LogtoErrorI18nKey } from '@logto/phrases'; import i18next from 'i18next'; -import initI18n from '@/i18n/init'; +import initI18n from '#src/i18n/init.js'; -import RequestError from '.'; +import RequestError from './index.js'; describe('RequestError', () => { beforeAll(async () => { diff --git a/packages/core/src/errors/SlonikError/slonik-error.test.ts b/packages/core/src/errors/SlonikError/slonik-error.test.ts index 32c98d26a..e5df10ff2 100644 --- a/packages/core/src/errors/SlonikError/slonik-error.test.ts +++ b/packages/core/src/errors/SlonikError/slonik-error.test.ts @@ -1,6 +1,6 @@ import { SlonikError } from 'slonik'; -import { DeletionError } from '.'; +import { DeletionError } from './index.js'; describe('SlonikError', () => { it('DeletionError', () => { diff --git a/packages/core/src/i18n/detect-language.test.ts b/packages/core/src/i18n/detect-language.test.ts index d16a1d30f..ab4e25814 100644 --- a/packages/core/src/i18n/detect-language.test.ts +++ b/packages/core/src/i18n/detect-language.test.ts @@ -1,7 +1,8 @@ -import { createMockContext } from '@shopify/jest-koa-mocks'; import type { ParameterizedContext } from 'koa'; -import detectLanguage from './detect-language'; +import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; + +import detectLanguage from './detect-language.js'; describe('detectLanguage', () => { it('detectLanguage with request header', () => { diff --git a/packages/core/src/include.d/clean-deep.d.ts b/packages/core/src/include.d/clean-deep.d.ts new file mode 100644 index 000000000..7ecf4cff1 --- /dev/null +++ b/packages/core/src/include.d/clean-deep.d.ts @@ -0,0 +1,18 @@ +// TODO: Remove this dependency + +declare module 'clean-deep' { + declare function cleanDeep(object: T, options?: CleanOptions): Partial; + + export default cleanDeep; + + export type CleanOptions = { + cleanKeys?: string[]; + cleanValues?: string[]; + emptyArrays?: boolean; + emptyObjects?: boolean; + emptyStrings?: boolean; + NaNValues?: boolean; + nullValues?: boolean; + undefinedValues?: boolean; + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbfdc56c0..e22655c8a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,12 +1,9 @@ -import 'module-alias/register'; - import Koa from 'koa'; -import initApp from './app/init'; -import { initConnectors } from './connectors'; -import envSet from './env-set'; -import { configDotEnv } from './env-set/dot-env'; -import initI18n from './i18n/init'; +import initApp from './app/init.js'; +import { configDotEnv } from './env-set/dot-env.js'; +import envSet from './env-set/index.js'; +import initI18n from './i18n/init.js'; // Update after we migrate to ESM // eslint-disable-next-line unicorn/prefer-top-level-await @@ -17,7 +14,6 @@ import initI18n from './i18n/init'; const app = new Koa({ proxy: envSet.values.trustProxyHeader, }); - await initConnectors(); await initI18n(); await initApp(app); } catch (error: unknown) { diff --git a/packages/core/src/lib/connector.test.ts b/packages/core/src/lib/connector.test.ts new file mode 100644 index 000000000..5380ed222 --- /dev/null +++ b/packages/core/src/lib/connector.test.ts @@ -0,0 +1,46 @@ +import { ConnectorType } from '@logto/schemas'; + +import { + mockMetadata0, + mockMetadata1, + mockConnector0, + mockConnector1, + mockLogtoConnector, + mockLogtoConnectorList, +} from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; + +import { checkSocialConnectorTargetAndPlatformUniqueness } from './connector.js'; + +describe('check social connector target and platform uniqueness', () => { + it('throws if more than one same-platform social connectors sharing the same `target`', () => { + const mockConnectors = [ + { + dbEntry: mockConnector0, + metadata: { ...mockMetadata0, target: 'target' }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + { + dbEntry: mockConnector1, + metadata: { ...mockMetadata1, target: 'target' }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]; + expect(() => { + checkSocialConnectorTargetAndPlatformUniqueness(mockConnectors); + }).toMatchError( + new RequestError({ + code: 'connector.multiple_target_with_same_platform', + status: 400, + }) + ); + }); + + it('should not throw when no multiple connectors sharing same target and platform', () => { + expect(() => { + checkSocialConnectorTargetAndPlatformUniqueness(mockLogtoConnectorList); + }).not.toThrow(); + }); +}); diff --git a/packages/core/src/lib/connector.ts b/packages/core/src/lib/connector.ts new file mode 100644 index 000000000..7c614b872 --- /dev/null +++ b/packages/core/src/lib/connector.ts @@ -0,0 +1,27 @@ +import { ConnectorType } from '@logto/schemas'; + +import type { LogtoConnector } from '#src/connectors/types.js'; +import assertThat from '#src/utils/assert-that.js'; + +export const checkSocialConnectorTargetAndPlatformUniqueness = (connectors: LogtoConnector[]) => { + const targetAndPlatformObjectsInUse = connectors + .filter(({ type }) => type === ConnectorType.Social) + .map(({ metadata: { target, platform } }) => ({ + target, + platform, + })); + + const targetAndPlatformSet = new Set(); + + for (const targetAndPlatformObject of targetAndPlatformObjectsInUse) { + const { target, platform } = targetAndPlatformObject; + + if (platform === null) { + continue; + } + + const element = JSON.stringify([target, platform]); + assertThat(!targetAndPlatformSet.has(element), 'connector.multiple_target_with_same_platform'); + targetAndPlatformSet.add(element); + } +}; diff --git a/packages/core/src/lib/logto-config.ts b/packages/core/src/lib/logto-config.ts index d43646c74..dfc0361fb 100644 --- a/packages/core/src/lib/logto-config.ts +++ b/packages/core/src/lib/logto-config.ts @@ -1,4 +1,4 @@ -import { getRowsByKeys } from '@logto/cli/lib/queries/logto-config'; +import { getRowsByKeys } from '@logto/cli/lib/queries/logto-config.js'; import type { LogtoOidcConfigType } from '@logto/schemas'; import { logtoOidcConfigGuard, LogtoOidcConfigKey } from '@logto/schemas'; import chalk from 'chalk'; diff --git a/packages/core/src/lib/passcode.test.ts b/packages/core/src/lib/passcode.test.ts index 254ecd19c..d4f531848 100644 --- a/packages/core/src/lib/passcode.test.ts +++ b/packages/core/src/lib/passcode.test.ts @@ -1,56 +1,47 @@ import { ConnectorType } from '@logto/connector-kit'; import type { Passcode } from '@logto/schemas'; import { PasscodeType } from '@logto/schemas'; +import { mockEsm } from '@logto/shared/esm'; import { any } from 'zod'; -import { mockConnector, mockMetadata } from '@/__mocks__'; -import { getLogtoConnectors } from '@/connectors'; -import { defaultConnectorMethods } from '@/connectors/consts'; -import RequestError from '@/errors/RequestError'; -import { - consumePasscode, - deletePasscodesByIds, +import { mockConnector, mockMetadata } from '#src/__mocks__/index.js'; +import { defaultConnectorMethods } from '#src/connectors/consts.js'; +import RequestError from '#src/errors/RequestError/index.js'; + +const { jest } = import.meta; + +const { findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodesByJtiAndType, + deletePasscodesByIds, increasePasscodeTryCount, insertPasscode, -} from '@/queries/passcode'; + consumePasscode, +} = mockEsm('#src/queries/passcode.js', () => ({ + findUnconsumedPasscodesByJtiAndType: jest.fn(), + findUnconsumedPasscodeByJtiAndType: jest.fn(), + deletePasscodesByIds: jest.fn(), + insertPasscode: jest.fn(), + consumePasscode: jest.fn(), + increasePasscodeTryCount: jest.fn(), +})); -import { +const { getLogtoConnectors } = mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectors: jest.fn(), +})); + +const { createPasscode, passcodeExpiration, passcodeMaxTryCount, passcodeLength, sendPasscode, verifyPasscode, -} from './passcode'; - -jest.mock('@/queries/passcode'); -jest.mock('@/connectors'); - -const mockedFindUnconsumedPasscodesByJtiAndType = - findUnconsumedPasscodesByJtiAndType as jest.MockedFunction< - typeof findUnconsumedPasscodesByJtiAndType - >; -const mockedFindUnconsumedPasscodeByJtiAndType = - findUnconsumedPasscodeByJtiAndType as jest.MockedFunction< - typeof findUnconsumedPasscodeByJtiAndType - >; -const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction< - typeof deletePasscodesByIds ->; -const mockedInsertPasscode = insertPasscode as jest.MockedFunction; -const mockedGetLogtoConnectors = getLogtoConnectors as jest.MockedFunction< - typeof getLogtoConnectors ->; -const mockedConsumePasscode = consumePasscode as jest.MockedFunction; -const mockedIncreasePasscodeTryCount = increasePasscodeTryCount as jest.MockedFunction< - typeof increasePasscodeTryCount ->; +} = await import('./passcode.js'); beforeAll(() => { - mockedFindUnconsumedPasscodesByJtiAndType.mockResolvedValue([]); - mockedInsertPasscode.mockImplementation(async (data): Promise => { + findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]); + insertPasscode.mockImplementation(async (data): Promise => { return { phone: null, email: null, @@ -88,7 +79,7 @@ describe('createPasscode', () => { it('should disable existing passcode', async () => { const email = 'jony@example.com'; const jti = 'jti'; - mockedFindUnconsumedPasscodesByJtiAndType.mockResolvedValue([ + findUnconsumedPasscodesByJtiAndType.mockResolvedValue([ { id: 'id', interactionJti: jti, @@ -104,7 +95,7 @@ describe('createPasscode', () => { await createPasscode(jti, PasscodeType.SignIn, { email, }); - expect(mockedDeletePasscodesByIds).toHaveBeenCalledWith(['id']); + expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']); }); }); @@ -127,7 +118,7 @@ describe('sendPasscode', () => { }); it('should throw error when email or sms connector can not be found', async () => { - mockedGetLogtoConnectors.mockResolvedValueOnce([ + getLogtoConnectors.mockResolvedValueOnce([ { ...defaultConnectorMethods, dbEntry: { @@ -164,7 +155,7 @@ describe('sendPasscode', () => { it('should call sendPasscode with params matching', async () => { const sendMessage = jest.fn(); - mockedGetLogtoConnectors.mockResolvedValueOnce([ + getLogtoConnectors.mockResolvedValueOnce([ { ...defaultConnectorMethods, configGuard: any(), @@ -230,20 +221,20 @@ describe('verifyPasscode', () => { }; it('should mark as consumed on successful verification', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); + findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); await verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' }); - expect(mockedConsumePasscode).toHaveBeenCalledWith(passcode.id); + expect(consumePasscode).toHaveBeenCalledWith(passcode.id); }); it('should fail when passcode not found', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(null); + findUnconsumedPasscodeByJtiAndType.mockResolvedValue(null); await expect( verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' }) ).rejects.toThrow(new RequestError('passcode.not_found')); }); it('should fail when phone mismatch', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); + findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); await expect( verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'invalid_phone', @@ -252,7 +243,7 @@ describe('verifyPasscode', () => { }); it('should fail when email mismatch', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({ + findUnconsumedPasscodeByJtiAndType.mockResolvedValue({ ...passcode, phone: null, email: 'email', @@ -265,7 +256,7 @@ describe('verifyPasscode', () => { }); it('should fail when expired', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({ + findUnconsumedPasscodeByJtiAndType.mockResolvedValue({ ...passcode, createdAt: Date.now() - passcodeExpiration - 100, }); @@ -275,7 +266,7 @@ describe('verifyPasscode', () => { }); it('should fail when exceed max count', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({ + findUnconsumedPasscodeByJtiAndType.mockResolvedValue({ ...passcode, tryCount: passcodeMaxTryCount, }); @@ -285,10 +276,10 @@ describe('verifyPasscode', () => { }); it('should fail when invalid code, and should increase try_count', async () => { - mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); + findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); await expect( verifyPasscode(passcode.interactionJti, passcode.type, 'invalid', { phone: 'phone' }) ).rejects.toThrow(new RequestError('passcode.code_mismatch')); - expect(mockedIncreasePasscodeTryCount).toHaveBeenCalledWith(passcode.id); + expect(increasePasscodeTryCount).toHaveBeenCalledWith(passcode.id); }); }); diff --git a/packages/core/src/lib/passcode.ts b/packages/core/src/lib/passcode.ts index 1cfe0c1cd..e122a20c1 100644 --- a/packages/core/src/lib/passcode.ts +++ b/packages/core/src/lib/passcode.ts @@ -3,10 +3,10 @@ import { messageTypesGuard, ConnectorError, ConnectorErrorCodes } from '@logto/c import type { Passcode, PasscodeType } from '@logto/schemas'; import { customAlphabet, nanoid } from 'nanoid'; -import { getLogtoConnectors } from '@/connectors'; -import type { LogtoConnector } from '@/connectors/types'; -import { ConnectorType } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; +import { getLogtoConnectors } from '#src/connectors/index.js'; +import type { LogtoConnector } from '#src/connectors/types.js'; +import { ConnectorType } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; import { consumePasscode, deletePasscodesByIds, @@ -14,8 +14,8 @@ import { findUnconsumedPasscodesByJtiAndType, increasePasscodeTryCount, insertPasscode, -} from '@/queries/passcode'; -import assertThat from '@/utils/assert-that'; +} from '#src/queries/passcode.js'; +import assertThat from '#src/utils/assert-that.js'; export const passcodeLength = 6; const randomCode = customAlphabet('1234567890', passcodeLength); @@ -53,7 +53,7 @@ export const sendPasscode = async (passcode: Passcode) => { const connector = connectors.find( (connector): connector is LogtoConnector => - connector.dbEntry.enabled && connector.type === expectType + connector.type === expectType ); assertThat( @@ -86,7 +86,6 @@ export const sendPasscode = async (passcode: Passcode) => { export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes. export const passcodeMaxTryCount = 10; -// TODO: @sijie refactor me // eslint-disable-next-line complexity export const verifyPasscode = async ( sessionId: string, diff --git a/packages/core/src/lib/phrase.test.ts b/packages/core/src/lib/phrase.test.ts index 4a0553988..607bb7169 100644 --- a/packages/core/src/lib/phrase.test.ts +++ b/packages/core/src/lib/phrase.test.ts @@ -1,5 +1,6 @@ import resource from '@logto/phrases-ui'; import type { CustomPhrase } from '@logto/schemas'; +import { mockEsm } from '@logto/shared/esm'; import deepmerge from 'deepmerge'; import { @@ -10,10 +11,10 @@ import { trTrTag, zhCnTag, zhHkTag, -} from '@/__mocks__/custom-phrase'; -import RequestError from '@/errors/RequestError'; -import { getPhrase } from '@/lib/phrase'; +} from '#src/__mocks__/custom-phrase.js'; +import RequestError from '#src/errors/RequestError/index.js'; +const { jest } = import.meta; const englishBuiltInPhrase = resource[enTag]; const customOnlyLanguage = zhHkTag; @@ -39,10 +40,12 @@ const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => { return mockCustomPhrase; }); -jest.mock('@/queries/custom-phrase', () => ({ - findCustomPhraseByLanguageTag: async (key: string) => findCustomPhraseByLanguageTag(key), +mockEsm('#src/queries/custom-phrase.js', () => ({ + findCustomPhraseByLanguageTag, })); +const { getPhrase } = await import('#src/lib/phrase.js'); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/packages/core/src/lib/phrase.ts b/packages/core/src/lib/phrase.ts index ab674e2ab..093ff4b97 100644 --- a/packages/core/src/lib/phrase.ts +++ b/packages/core/src/lib/phrase.ts @@ -4,7 +4,7 @@ import type { CustomPhrase } from '@logto/schemas'; import cleanDeep from 'clean-deep'; import deepmerge from 'deepmerge'; -import { findCustomPhraseByLanguageTag } from '@/queries/custom-phrase'; +import { findCustomPhraseByLanguageTag } from '#src/queries/custom-phrase.js'; export const getPhrase = async (supportedLanguage: string, customLanguages: string[]) => { if (!isBuiltInLanguageTag(supportedLanguage)) { diff --git a/packages/core/src/lib/session.ts b/packages/core/src/lib/session.ts index ce3003294..32102419e 100644 --- a/packages/core/src/lib/session.ts +++ b/packages/core/src/lib/session.ts @@ -3,9 +3,9 @@ import type { Context } from 'koa'; import type { InteractionResults, Provider } from 'oidc-provider'; import { errors } from 'oidc-provider'; -import RequestError from '@/errors/RequestError'; -import { findUserById, updateUserById } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { findUserById, updateUserById } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; export const assignInteractionResults = async ( ctx: Context, diff --git a/packages/core/src/lib/sign-in-experience/index.test.ts b/packages/core/src/lib/sign-in-experience/index.test.ts index b871a0469..b41930a41 100644 --- a/packages/core/src/lib/sign-in-experience/index.test.ts +++ b/packages/core/src/lib/sign-in-experience/index.test.ts @@ -2,51 +2,45 @@ import type { LanguageTag } from '@logto/language-kit'; import { builtInLanguages } from '@logto/phrases-ui'; import type { CreateSignInExperience, SignInExperience } from '@logto/schemas'; import { BrandingStyle } from '@logto/schemas'; +import { mockEsm } from '@logto/shared/esm'; import { - disabledSocialTarget01, - disabledSocialTarget02, - enabledSocialTarget01, + socialTarget01, + socialTarget02, mockBranding, mockSignInExperience, mockSocialConnectors, -} from '@/__mocks__'; -import type { LogtoConnector } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import { +} from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; + +const { jest } = import.meta; +const allCustomLanguageTags: LanguageTag[] = []; + +const { findAllCustomLanguageTags } = mockEsm('#src/queries/custom-phrase.js', () => ({ + findAllCustomLanguageTags: jest.fn(async () => allCustomLanguageTags), +})); +const { getLogtoConnectors } = mockEsm('#src/connectors.js', () => ({ + getLogtoConnectors: jest.fn(), +})); +const { findDefaultSignInExperience, updateDefaultSignInExperience } = mockEsm( + '#src/queries/sign-in-experience.js', + () => ({ + findDefaultSignInExperience: jest.fn(), + updateDefaultSignInExperience: jest.fn( + async (data: Partial): Promise => ({ + ...mockSignInExperience, + ...data, + }) + ), + }) +); + +const { validateBranding, validateTermsOfUse, validateLanguageInfo, removeUnavailableSocialConnectorTargets, -} from '@/lib/sign-in-experience'; -import { updateDefaultSignInExperience } from '@/queries/sign-in-experience'; - -const allCustomLanguageTags: LanguageTag[] = []; -const findAllCustomLanguageTags = jest.fn(async () => allCustomLanguageTags); -const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction< - () => Promise ->; -const findDefaultSignInExperience = jest.fn() as jest.MockedFunction< - () => Promise ->; - -jest.mock('@/queries/custom-phrase', () => ({ - findAllCustomLanguageTags: async () => findAllCustomLanguageTags(), -})); - -jest.mock('@/connectors', () => ({ - getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(), -})); - -jest.mock('@/queries/sign-in-experience', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), - updateDefaultSignInExperience: jest.fn( - async (data: Partial): Promise => ({ - ...mockSignInExperience, - ...data, - }) - ), -})); +} = await import('./index.js'); beforeEach(() => { jest.clearAllMocks(); @@ -164,15 +158,11 @@ describe('remove unavailable social connector targets', () => { ...mockSignInExperience, socialSignInConnectorTargets: mockSocialConnectorTargets, }); - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockSocialConnectors); - expect(mockSocialConnectorTargets).toEqual([ - disabledSocialTarget01, - enabledSocialTarget01, - disabledSocialTarget02, - ]); + getLogtoConnectors.mockResolvedValueOnce(mockSocialConnectors); + expect(mockSocialConnectorTargets).toEqual([socialTarget01, socialTarget02]); await removeUnavailableSocialConnectorTargets(); expect(updateDefaultSignInExperience).toBeCalledWith({ - socialSignInConnectorTargets: [enabledSocialTarget01], + socialSignInConnectorTargets: [socialTarget01, socialTarget02], }); }); }); diff --git a/packages/core/src/lib/sign-in-experience/index.ts b/packages/core/src/lib/sign-in-experience/index.ts index 5f90f04fd..2f81eb6c3 100644 --- a/packages/core/src/lib/sign-in-experience/index.ts +++ b/packages/core/src/lib/sign-in-experience/index.ts @@ -5,21 +5,22 @@ import { adminConsoleApplicationId, adminConsoleSignInExperience, demoAppApplicationId, -} from '@logto/schemas/lib/seeds'; +} from '@logto/schemas/lib/seeds/index.js'; +import { deduplicate } from '@silverhand/essentials'; import i18next from 'i18next'; -import { getLogtoConnectors } from '@/connectors'; -import RequestError from '@/errors/RequestError'; -import { findAllCustomLanguageTags } from '@/queries/custom-phrase'; +import { getLogtoConnectors } from '#src/connectors/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { findAllCustomLanguageTags } from '#src/queries/custom-phrase.js'; import { findDefaultSignInExperience, updateDefaultSignInExperience, -} from '@/queries/sign-in-experience'; -import { hasActiveUsers } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +} from '#src/queries/sign-in-experience.js'; +import { hasActiveUsers } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -export * from './sign-up'; -export * from './sign-in'; +export * from './sign-up.js'; +export * from './sign-in.js'; export const validateBranding = (branding: Branding) => { if (branding.style === BrandingStyle.Logo_Slogan) { @@ -50,16 +51,16 @@ export const validateTermsOfUse = (termsOfUse: TermsOfUse) => { export const removeUnavailableSocialConnectorTargets = async () => { const connectors = await getLogtoConnectors(); - const availableSocialConnectorTargets = new Set( + const availableSocialConnectorTargets = deduplicate( connectors - .filter(({ type, dbEntry: { enabled } }) => enabled && type === ConnectorType.Social) + .filter(({ type }) => type === ConnectorType.Social) .map(({ metadata: { target } }) => target) ); const { socialSignInConnectorTargets } = await findDefaultSignInExperience(); await updateDefaultSignInExperience({ socialSignInConnectorTargets: socialSignInConnectorTargets.filter((target) => - availableSocialConnectorTargets.has(target) + availableSocialConnectorTargets.includes(target) ), }); }; diff --git a/packages/core/src/lib/sign-in-experience/sign-in.test.ts b/packages/core/src/lib/sign-in-experience/sign-in.test.ts index acc69de51..7531983bc 100644 --- a/packages/core/src/lib/sign-in-experience/sign-in.test.ts +++ b/packages/core/src/lib/sign-in-experience/sign-in.test.ts @@ -1,14 +1,14 @@ -import { ConnectorType, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { ConnectorType, SignInIdentifier } from '@logto/schemas'; import { mockAliyunDmConnector, mockAliyunSmsConnector, mockSignInMethod, mockSignUp, -} from '@/__mocks__'; -import RequestError from '@/errors/RequestError'; +} from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; -import { validateSignIn } from './sign-in'; +import { validateSignIn } from './sign-in.js'; const enabledConnectors = [mockAliyunDmConnector, mockAliyunSmsConnector]; @@ -33,7 +33,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.EmailOrSms, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], password: false, verify: true, }, @@ -56,7 +56,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], password: true, }, [] @@ -65,8 +65,8 @@ describe('validate sign-in', () => { }); }); - describe('There must be at least one enabled connector for the specific identifier.', () => { - it('throws when there is no enabled email connector and identifiers includes email with verification code checked', () => { + describe('There must be at least one connector for the specific identifier.', () => { + it('throws when there is no email connector and identifiers includes email with verification code checked', () => { expect(() => { validateSignIn( { @@ -89,7 +89,7 @@ describe('validate sign-in', () => { ); }); - it('throws when there is no enabled sms connector and identifiers includes phone with verification code checked', () => { + it('throws when there is no sms connector and identifiers includes phone with verification code checked', () => { expect(() => { validateSignIn( { @@ -127,7 +127,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], }, enabledConnectors ); @@ -151,7 +151,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], }, enabledConnectors ); @@ -175,7 +175,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Sms, + identifiers: [SignInIdentifier.Sms], }, enabledConnectors ); @@ -199,7 +199,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.EmailOrSms, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], }, enabledConnectors ); @@ -226,7 +226,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], password: true, }, enabledConnectors @@ -252,7 +252,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], password: false, verify: true, }, @@ -286,7 +286,7 @@ describe('validate sign-in', () => { }, { ...mockSignUp, - identifier: SignUpIdentifier.Sms, + identifiers: [SignInIdentifier.Sms], password: false, verify: true, }, diff --git a/packages/core/src/lib/sign-in-experience/sign-in.ts b/packages/core/src/lib/sign-in-experience/sign-in.ts index 6f06fa6b5..09e41c88d 100644 --- a/packages/core/src/lib/sign-in-experience/sign-in.ts +++ b/packages/core/src/lib/sign-in-experience/sign-in.ts @@ -1,9 +1,9 @@ import type { SignIn, SignUp } from '@logto/schemas'; -import { ConnectorType, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { ConnectorType, SignInIdentifier } from '@logto/schemas'; -import type { LogtoConnector } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import assertThat from '@/utils/assert-that'; +import type { LogtoConnector } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; /* eslint-disable complexity */ export const validateSignIn = ( @@ -47,56 +47,33 @@ export const validateSignIn = ( }) ); - switch (signUp.identifier) { - case SignUpIdentifier.Username: { + for (const identifier of signUp.identifiers) { + if (identifier === SignInIdentifier.Username) { assertThat( signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Username), new RequestError({ code: 'sign_in_experiences.miss_sign_up_identifier_in_sign_in', }) ); - - break; } - case SignUpIdentifier.Email: { + if (identifier === SignInIdentifier.Email) { assertThat( signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Email), new RequestError({ code: 'sign_in_experiences.miss_sign_up_identifier_in_sign_in', }) ); - - break; } - case SignUpIdentifier.Sms: { + if (identifier === SignInIdentifier.Sms) { assertThat( signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Sms), new RequestError({ code: 'sign_in_experiences.miss_sign_up_identifier_in_sign_in', }) ); - - break; } - - case SignUpIdentifier.EmailOrSms: { - assertThat( - signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Email) && - signIn.methods.some(({ identifier }) => identifier === SignInIdentifier.Sms), - new RequestError({ - code: 'sign_in_experiences.miss_sign_up_identifier_in_sign_in', - }) - ); - - break; - } - - case SignUpIdentifier.None: { - // No requirement - } - // No default } if (signUp.password) { diff --git a/packages/core/src/lib/sign-in-experience/sign-up.test.ts b/packages/core/src/lib/sign-in-experience/sign-up.test.ts index f465063ce..8516237f1 100644 --- a/packages/core/src/lib/sign-in-experience/sign-up.test.ts +++ b/packages/core/src/lib/sign-in-experience/sign-up.test.ts @@ -1,22 +1,23 @@ -import { ConnectorType, SignUpIdentifier } from '@logto/schemas'; +import { ConnectorType, SignInIdentifier } from '@logto/schemas'; +import { mockEsmWithActual } from '@logto/shared/esm'; -import { mockAliyunDmConnector, mockAliyunSmsConnector, mockSignUp } from '@/__mocks__'; -import RequestError from '@/errors/RequestError'; - -import { validateSignUp } from './sign-up'; +import { mockAliyunDmConnector, mockAliyunSmsConnector, mockSignUp } from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +const { jest } = import.meta; const enabledConnectors = [mockAliyunDmConnector, mockAliyunSmsConnector]; -jest.mock('@/lib/session', () => ({ - ...jest.requireActual('@/lib/session'), +await mockEsmWithActual('#src/lib/session.js', () => ({ getApplicationIdFromInteraction: jest.fn(), })); +const { validateSignUp } = await import('./sign-up.js'); + describe('validate sign-up', () => { - describe('There must be at least one enabled connector for the specific identifier.', () => { - test('should throw when there is no enabled email connector and identifier is email', async () => { + describe('There must be at least one connector for the specific identifier.', () => { + test('should throw when there is no email connector and identifier is email', async () => { expect(() => { - validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.Email }, []); + validateSignUp({ ...mockSignUp, identifiers: [SignInIdentifier.Email] }, []); }).toMatchError( new RequestError({ code: 'sign_in_experiences.enabled_connector_not_found', @@ -25,9 +26,15 @@ describe('validate sign-up', () => { ); }); - test('should throw when there is no enabled email connector and identifier is email or phone', async () => { + test('should throw when there is no email connector and identifier is email or phone', async () => { expect(() => { - validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms }, []); + validateSignUp( + { + ...mockSignUp, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], + }, + [] + ); }).toMatchError( new RequestError({ code: 'sign_in_experiences.enabled_connector_not_found', @@ -36,9 +43,9 @@ describe('validate sign-up', () => { ); }); - test('should throw when there is no enabled sms connector and identifier is phone', async () => { + test('should throw when there is no sms connector and identifier is phone', async () => { expect(() => { - validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.Sms }, []); + validateSignUp({ ...mockSignUp, identifiers: [SignInIdentifier.Sms] }, []); }).toMatchError( new RequestError({ code: 'sign_in_experiences.enabled_connector_not_found', @@ -47,11 +54,16 @@ describe('validate sign-up', () => { ); }); - test('should throw when there is no enabled email connector and identifier is email or phone', async () => { + test('should throw when there is no email connector and identifier is email or phone', async () => { expect(() => { - validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms }, [ - mockAliyunDmConnector, - ]); + validateSignUp( + { + ...mockSignUp, + verify: true, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], + }, + [mockAliyunSmsConnector] + ); }).toMatchError( new RequestError({ code: 'sign_in_experiences.enabled_connector_not_found', @@ -64,7 +76,7 @@ describe('validate sign-up', () => { test('should throw when identifier is username and password is false', async () => { expect(() => { validateSignUp( - { ...mockSignUp, identifier: SignUpIdentifier.Username, password: false }, + { ...mockSignUp, identifiers: [SignInIdentifier.Username], password: false }, enabledConnectors ); }).toMatchError( @@ -78,7 +90,7 @@ describe('validate sign-up', () => { test('should throw when identifier is email', async () => { expect(() => { validateSignUp( - { ...mockSignUp, identifier: SignUpIdentifier.Email, verify: false }, + { ...mockSignUp, identifiers: [SignInIdentifier.Email], verify: false }, enabledConnectors ); }).toMatchError( @@ -91,7 +103,7 @@ describe('validate sign-up', () => { test('should throw when identifier is phone', async () => { expect(() => { validateSignUp( - { ...mockSignUp, identifier: SignUpIdentifier.Email, verify: false }, + { ...mockSignUp, identifiers: [SignInIdentifier.Email], verify: false }, enabledConnectors ); }).toMatchError( @@ -104,7 +116,11 @@ describe('validate sign-up', () => { test('should throw when identifier is email or phone', async () => { expect(() => { validateSignUp( - { ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms, verify: false }, + { + ...mockSignUp, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], + verify: false, + }, enabledConnectors ); }).toMatchError( diff --git a/packages/core/src/lib/sign-in-experience/sign-up.ts b/packages/core/src/lib/sign-in-experience/sign-up.ts index 2cde0c596..601fc0cda 100644 --- a/packages/core/src/lib/sign-in-experience/sign-up.ts +++ b/packages/core/src/lib/sign-in-experience/sign-up.ts @@ -1,56 +1,48 @@ import type { SignUp } from '@logto/schemas'; -import { ConnectorType, SignUpIdentifier } from '@logto/schemas'; +import { SignInIdentifier, ConnectorType } from '@logto/schemas'; -import type { LogtoConnector } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import assertThat from '@/utils/assert-that'; +import type { LogtoConnector } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; export const validateSignUp = (signUp: SignUp, enabledConnectors: LogtoConnector[]) => { - if ( - signUp.identifier === SignUpIdentifier.Email || - signUp.identifier === SignUpIdentifier.EmailOrSms - ) { - assertThat( - enabledConnectors.some((item) => item.type === ConnectorType.Email), - new RequestError({ - code: 'sign_in_experiences.enabled_connector_not_found', - type: ConnectorType.Email, - }) - ); - } + for (const identifier of signUp.identifiers) { + if (identifier === SignInIdentifier.Email) { + assertThat( + enabledConnectors.some((item) => item.type === ConnectorType.Email), + new RequestError({ + code: 'sign_in_experiences.enabled_connector_not_found', + type: ConnectorType.Email, + }) + ); + } - if ( - signUp.identifier === SignUpIdentifier.Sms || - signUp.identifier === SignUpIdentifier.EmailOrSms - ) { - assertThat( - enabledConnectors.some((item) => item.type === ConnectorType.Sms), - new RequestError({ - code: 'sign_in_experiences.enabled_connector_not_found', - type: ConnectorType.Sms, - }) - ); - } + if (identifier === SignInIdentifier.Sms) { + assertThat( + enabledConnectors.some((item) => item.type === ConnectorType.Sms), + new RequestError({ + code: 'sign_in_experiences.enabled_connector_not_found', + type: ConnectorType.Sms, + }) + ); + } - if (signUp.identifier === SignUpIdentifier.Username) { - assertThat( - signUp.password, - new RequestError({ - code: 'sign_in_experiences.username_requires_password', - }) - ); - } + if (identifier === SignInIdentifier.Username) { + assertThat( + signUp.password, + new RequestError({ + code: 'sign_in_experiences.username_requires_password', + }) + ); + } - if ( - [SignUpIdentifier.Sms, SignUpIdentifier.Email, SignUpIdentifier.EmailOrSms].includes( - signUp.identifier - ) - ) { - assertThat( - signUp.verify, - new RequestError({ - code: 'sign_in_experiences.passwordless_requires_verify', - }) - ); + if (identifier === SignInIdentifier.Email || identifier === SignInIdentifier.Sms) { + assertThat( + signUp.verify, + new RequestError({ + code: 'sign_in_experiences.passwordless_requires_verify', + }) + ); + } } }; diff --git a/packages/core/src/lib/social.ts b/packages/core/src/lib/social.ts index c304dfbf7..6662186af 100644 --- a/packages/core/src/lib/social.ts +++ b/packages/core/src/lib/social.ts @@ -4,12 +4,12 @@ import type { Nullable } from '@silverhand/essentials'; import type { InteractionResults } from 'oidc-provider'; import { z } from 'zod'; -import { getLogtoConnectorById } from '@/connectors'; -import type { SocialUserInfo } from '@/connectors/types'; -import { socialUserInfoGuard } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import { findUserByEmail, findUserByPhone } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import type { SocialUserInfo } from '#src/connectors/types.js'; +import { socialUserInfoGuard } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { findUserByEmail, findUserByPhone } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; export type SocialUserInfoSession = { connectorId: string; diff --git a/packages/core/src/lib/user.test.ts b/packages/core/src/lib/user.test.ts index 75be699c2..54d75926b 100644 --- a/packages/core/src/lib/user.test.ts +++ b/packages/core/src/lib/user.test.ts @@ -1,20 +1,22 @@ import { UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { mockEsmWithActual } from '@logto/shared/esm'; -import { hasUserWithId, updateUserById } from '@/queries/user'; +const { jest } = import.meta; -import { encryptUserPassword, generateUserId } from './user'; +const { updateUserById, hasUserWithId } = await mockEsmWithActual('#src/queries/user.js', () => ({ + updateUserById: jest.fn(), + hasUserWithId: jest.fn(), +})); -jest.mock('@/queries/user'); +const { encryptUserPassword, generateUserId } = await import('./user.js'); describe('generateUserId()', () => { afterEach(() => { - (hasUserWithId as jest.MockedFunction).mockClear(); + hasUserWithId.mockClear(); }); it('generates user ID with correct length when no conflict found', async () => { - const mockedHasUserWithId = ( - hasUserWithId as jest.MockedFunction - ).mockImplementationOnce(async () => false); + const mockedHasUserWithId = hasUserWithId.mockImplementationOnce(async () => false); await expect(generateUserId()).resolves.toHaveLength(12); expect(mockedHasUserWithId).toBeCalledTimes(1); @@ -23,9 +25,7 @@ describe('generateUserId()', () => { it('generates user ID with correct length when retry limit is not reached', async () => { // eslint-disable-next-line @silverhand/fp/no-let let tried = 0; - const mockedHasUserWithId = ( - hasUserWithId as jest.MockedFunction - ).mockImplementation(async () => { + const mockedHasUserWithId = hasUserWithId.mockImplementation(async () => { if (tried) { return false; } @@ -41,9 +41,7 @@ describe('generateUserId()', () => { }); it('rejects with correct error message when retry limit is reached', async () => { - const mockedHasUserWithId = ( - hasUserWithId as jest.MockedFunction - ).mockImplementation(async () => true); + const mockedHasUserWithId = hasUserWithId.mockImplementation(async () => true); await expect(generateUserId(10)).rejects.toThrow( 'Cannot generate user ID in reasonable retries' diff --git a/packages/core/src/lib/user.ts b/packages/core/src/lib/user.ts index ca3c2e229..8f190c903 100644 --- a/packages/core/src/lib/user.ts +++ b/packages/core/src/lib/user.ts @@ -2,17 +2,20 @@ import type { User, CreateUser } from '@logto/schemas'; import { Users, UsersPasswordEncryptionMethod } from '@logto/schemas'; import { buildIdGenerator } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; +import { deduplicate } from '@silverhand/essentials'; import { argon2Verify } from 'hash-wasm'; import pRetry from 'p-retry'; -import { buildInsertInto } from '@/database/insert-into'; -import envSet from '@/env-set'; -import { findRolesByRoleNames, insertRoles } from '@/queries/roles'; -import { hasUserWithId } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; -import { encryptPassword } from '@/utils/password'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import envSet from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { findRolesByRoleNames, insertRoles } from '#src/queries/roles.js'; +import { hasUser, hasUserWithEmail, hasUserWithId, hasUserWithPhone } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; +import { encryptPassword } from '#src/utils/password.js'; const userId = buildIdGenerator(12); +const roleId = buildIdGenerator(21); export const generateUserId = async (retries = 500) => pRetry( @@ -64,9 +67,9 @@ const insertUserQuery = buildInsertInto(Users, { // Temp solution since Hasura requires a role to proceed authn. // The source of default roles should be guarded and moved to database once we implement RBAC. export const insertUser: typeof insertUserQuery = async ({ roleNames, ...rest }) => { - const computedRoleNames = [ - ...new Set((roleNames ?? []).concat(envSet.values.userDefaultRoleNames)), - ]; + const computedRoleNames = deduplicate( + (roleNames ?? []).concat(envSet.values.userDefaultRoleNames) + ); if (computedRoleNames.length > 0) { const existingRoles = await findRolesByRoleNames(computedRoleNames); @@ -76,10 +79,37 @@ export const insertUser: typeof insertUserQuery = async ({ roleNames, ...rest }) if (missingRoleNames.length > 0) { await insertRoles( - missingRoleNames.map((name) => ({ name, description: 'User default role.' })) + missingRoleNames.map((name) => ({ + id: roleId(), + name, + description: 'User default role.', + })) ); } } return insertUserQuery({ roleNames: computedRoleNames, ...rest }); }; + +export const checkIdentifierCollision = async ( + identifiers: { + username?: Nullable; + primaryEmail?: Nullable; + primaryPhone?: Nullable; + }, + excludeUserId?: string +) => { + const { username, primaryEmail, primaryPhone } = identifiers; + + if (username && (await hasUser(username, excludeUserId))) { + throw new RequestError({ code: 'user.username_already_in_use', status: 422 }); + } + + if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) { + throw new RequestError({ code: 'user.email_already_in_use', status: 422 }); + } + + if (primaryPhone && (await hasUserWithPhone(primaryPhone, excludeUserId))) { + throw new RequestError({ code: 'user.phone_already_in_use', status: 422 }); + } +}; diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index b473f46df..a064bec7a 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -1,19 +1,22 @@ import { UserRole } from '@logto/schemas'; -import { jwtVerify } from 'jose'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; import type { Context } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '@/env-set'; -import RequestError from '@/errors/RequestError'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import envSet from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import type { WithAuthContext } from './koa-auth'; -import koaAuth from './koa-auth'; +import type { WithAuthContext } from './koa-auth.js'; -jest.mock('jose', () => ({ - jwtVerify: jest.fn(() => ({ payload: { sub: 'fooUser', role_names: ['admin'] } })), +const { jest } = import.meta; + +const { jwtVerify } = mockEsm('jose', () => ({ + jwtVerify: jest.fn().mockReturnValue({ payload: { sub: 'fooUser', role_names: ['admin'] } }), })); +const koaAuth = await pickDefault(import('./koa-auth.js')); + describe('koaAuth middleware', () => { const baseCtx = createContextWithRouteParameters(); @@ -136,8 +139,7 @@ describe('koaAuth middleware', () => { }); it('expect to throw if jwt sub is missing', async () => { - const mockJwtVerify = jwtVerify as jest.Mock; - mockJwtVerify.mockImplementationOnce(() => ({ payload: {} })); + jwtVerify.mockImplementationOnce(() => ({ payload: {} })); ctx.request = { ...ctx.request, @@ -150,8 +152,7 @@ describe('koaAuth middleware', () => { }); it('expect to have `client` type per jwt verify result', async () => { - const mockJwtVerify = jwtVerify as jest.Mock; - mockJwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'bar', client_id: 'bar' } })); + jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'bar', client_id: 'bar' } })); ctx.request = { ...ctx.request, @@ -165,8 +166,7 @@ describe('koaAuth middleware', () => { }); it('expect to throw if jwt role_names is missing', async () => { - const mockJwtVerify = jwtVerify as jest.Mock; - mockJwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser' } })); + jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser' } })); ctx.request = { ...ctx.request, @@ -179,8 +179,7 @@ describe('koaAuth middleware', () => { }); it('expect to throw if jwt role_names does not include admin', async () => { - const mockJwtVerify = jwtVerify as jest.Mock; - mockJwtVerify.mockImplementationOnce(() => ({ + jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser', role_names: ['foo'] }, })); @@ -195,8 +194,7 @@ describe('koaAuth middleware', () => { }); it('expect to throw unauthorized error if unknown error occurs', async () => { - const mockJwtVerify = jwtVerify as jest.Mock; - mockJwtVerify.mockImplementationOnce(() => { + jwtVerify.mockImplementationOnce(() => { throw new Error('unknown error'); }); ctx.request = { diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index 07e3b1778..69bcff510 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -1,16 +1,16 @@ import type { IncomingHttpHeaders } from 'http'; import { UserRole } from '@logto/schemas'; -import { managementResource } from '@logto/schemas/lib/seeds'; +import { managementResource } from '@logto/schemas/lib/seeds/index.js'; import type { Optional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials'; import { jwtVerify } from 'jose'; import type { MiddlewareType, Request } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '@/env-set'; -import RequestError from '@/errors/RequestError'; -import assertThat from '@/utils/assert-that'; +import envSet from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; export type Auth = { type: 'user' | 'app'; diff --git a/packages/core/src/middleware/koa-check-demo-app.ts b/packages/core/src/middleware/koa-check-demo-app.ts index d407d6c15..e005c1ba5 100644 --- a/packages/core/src/middleware/koa-check-demo-app.ts +++ b/packages/core/src/middleware/koa-check-demo-app.ts @@ -1,7 +1,7 @@ -import { demoAppApplicationId } from '@logto/schemas/lib/seeds'; +import { demoAppApplicationId } from '@logto/schemas/lib/seeds/index.js'; import type { MiddlewareType } from 'koa'; -import { findApplicationById } from '@/queries/application'; +import { findApplicationById } from '#src/queries/application.js'; export default function koaCheckDemoApp(): MiddlewareType< StateT, diff --git a/packages/core/src/middleware/koa-connector-error-handler.test.ts b/packages/core/src/middleware/koa-connector-error-handler.test.ts index 09ca4f8f3..aa425465d 100644 --- a/packages/core/src/middleware/koa-connector-error-handler.test.ts +++ b/packages/core/src/middleware/koa-connector-error-handler.test.ts @@ -1,9 +1,11 @@ import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; -import RequestError from '@/errors/RequestError'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaConnectorErrorHandler from './koa-connector-error-handler'; +import koaConnectorErrorHandler from './koa-connector-error-handler.js'; + +const { jest } = import.meta; describe('koaConnectorErrorHandler middleware', () => { const next = jest.fn(); diff --git a/packages/core/src/middleware/koa-connector-error-handler.ts b/packages/core/src/middleware/koa-connector-error-handler.ts index 2bcaa715d..843da4175 100644 --- a/packages/core/src/middleware/koa-connector-error-handler.ts +++ b/packages/core/src/middleware/koa-connector-error-handler.ts @@ -3,7 +3,7 @@ import { conditional } from '@silverhand/essentials'; import type { Middleware } from 'koa'; import { z } from 'zod'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; export default function koaConnectorErrorHandler(): Middleware { // Too many error types :-) diff --git a/packages/core/src/middleware/koa-error-handler.test.ts b/packages/core/src/middleware/koa-error-handler.test.ts index bf25765e3..450ec4d26 100644 --- a/packages/core/src/middleware/koa-error-handler.test.ts +++ b/packages/core/src/middleware/koa-error-handler.test.ts @@ -1,9 +1,11 @@ -import { createMockContext } from '@shopify/jest-koa-mocks'; import createHttpError from 'http-errors'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; +import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; -import koaErrorHandler from './koa-error-handler'; +import koaErrorHandler from './koa-error-handler.js'; + +const { jest } = import.meta; describe('koaErrorHandler middleware', () => { const mockBody = { data: 'foo' }; diff --git a/packages/core/src/middleware/koa-error-handler.ts b/packages/core/src/middleware/koa-error-handler.ts index 19f9f78b8..e4f029e52 100644 --- a/packages/core/src/middleware/koa-error-handler.ts +++ b/packages/core/src/middleware/koa-error-handler.ts @@ -2,8 +2,8 @@ import type { RequestErrorBody } from '@logto/schemas'; import type { Middleware } from 'koa'; import { HttpError } from 'koa'; -import envSet from '@/env-set'; -import RequestError from '@/errors/RequestError'; +import envSet from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; export default function koaErrorHandler(): Middleware< StateT, diff --git a/packages/core/src/middleware/koa-guard.test.ts b/packages/core/src/middleware/koa-guard.test.ts index ee052c00f..8342be47c 100644 --- a/packages/core/src/middleware/koa-guard.test.ts +++ b/packages/core/src/middleware/koa-guard.test.ts @@ -1,10 +1,12 @@ +import { mockEsmDefault } from '@logto/shared/esm'; import { z } from 'zod'; -import { emptyMiddleware, createContextWithRouteParameters } from '@/utils/test-utils'; +import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaGuard, { isGuardMiddleware } from './koa-guard'; +const { jest } = import.meta; -jest.mock('koa-body', () => emptyMiddleware); +mockEsmDefault('koa-body', () => emptyMiddleware); +const { default: koaGuard, isGuardMiddleware } = await import('./koa-guard.js'); describe('koaGuardMiddleware', () => { describe('isGuardMiddleware', () => { diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 45ad13c74..c24480425 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -5,10 +5,10 @@ import koaBody from 'koa-body'; import type { IMiddleware, IRouterParamContext } from 'koa-router'; import type { ZodType, ZodTypeDef } from 'zod'; -import envSet from '@/env-set'; -import RequestError from '@/errors/RequestError'; -import ServerError from '@/errors/ServerError'; -import assertThat from '@/utils/assert-that'; +import envSet from '#src/env-set/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import ServerError from '#src/errors/ServerError/index.js'; +import assertThat from '#src/utils/assert-that.js'; export type GuardConfig = { query?: ZodType; diff --git a/packages/core/src/middleware/koa-i18next.test.ts b/packages/core/src/middleware/koa-i18next.test.ts index 9f9b45bdf..1f6f4adf0 100644 --- a/packages/core/src/middleware/koa-i18next.test.ts +++ b/packages/core/src/middleware/koa-i18next.test.ts @@ -1,19 +1,20 @@ +import { mockEsmDefault, pickDefault } from '@logto/shared/esm'; import i18next from 'i18next'; -import initI18n from '@/i18n/init'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaI18next from './koa-i18next'; +const { jest } = import.meta; +const mockLanguage = () => ['zh-cn']; +mockEsmDefault('#src/i18n/detect-language.js', () => mockLanguage); -// Can not access outter scope function in jest mock -// eslint-disable-next-line unicorn/consistent-function-scoping -jest.mock('@/i18n/detect-language', () => () => ['zh-cn']); +const initI18n = await pickDefault(import('#src/i18n/init.js')); +const koaI18next = await pickDefault(import('./koa-i18next.js')); const changLanguageSpy = jest.spyOn(i18next, 'changeLanguage'); describe('koaI18next', () => { const next = jest.fn(); - it('deteact language', async () => { + it('detect language', async () => { const ctx = { ...createContextWithRouteParameters(), query: {}, diff --git a/packages/core/src/middleware/koa-i18next.ts b/packages/core/src/middleware/koa-i18next.ts index 304f83b8b..d6c279857 100644 --- a/packages/core/src/middleware/koa-i18next.ts +++ b/packages/core/src/middleware/koa-i18next.ts @@ -1,8 +1,14 @@ -import i18next from 'i18next'; +import type { i18n } from 'i18next'; +import _i18next from 'i18next'; import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import detectLanguage from '@/i18n/detect-language'; +import detectLanguage from '#src/i18n/detect-language.js'; + +// This may be fixed by a cjs require wrapper. TBD. +// See https://github.com/microsoft/TypeScript/issues/49189 +// eslint-disable-next-line no-restricted-syntax +const i18next = _i18next as unknown as i18n; type LanguageUtils = { formatLanguageCode(code: string): string; diff --git a/packages/core/src/middleware/koa-log-session.test.ts b/packages/core/src/middleware/koa-log-session.test.ts index c652222b8..2cfb4467c 100644 --- a/packages/core/src/middleware/koa-log-session.test.ts +++ b/packages/core/src/middleware/koa-log-session.test.ts @@ -1,16 +1,13 @@ import { Provider } from 'oidc-provider'; -import type { WithLogContext } from '@/middleware/koa-log'; -import koaLogSession from '@/middleware/koa-log-session'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import koaLogSession from '#src/middleware/koa-log-session.js'; +import type { WithLogContext } from '#src/middleware/koa-log.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); +const { jest } = import.meta; -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - })), -})); +const provider = new Provider('https://logto.test'); +const interactionDetails = jest.spyOn(provider, 'interactionDetails'); describe('koaLogSession', () => { const sessionId = 'sessionId'; @@ -19,6 +16,7 @@ describe('koaLogSession', () => { const log = jest.fn(); const next = jest.fn(); + // @ts-expect-error for testing interactionDetails.mockResolvedValue({ jti: sessionId, params: { @@ -37,7 +35,7 @@ describe('koaLogSession', () => { log, }; - await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); expect(interactionDetails).toHaveBeenCalled(); }); @@ -48,7 +46,7 @@ describe('koaLogSession', () => { log, }; - await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId }); }); @@ -59,7 +57,7 @@ describe('koaLogSession', () => { log, }; - await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); expect(next).toHaveBeenCalled(); }); @@ -74,6 +72,6 @@ describe('koaLogSession', () => { throw new Error('message'); }); - await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow(); + await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow(); }); }); diff --git a/packages/core/src/middleware/koa-log-session.ts b/packages/core/src/middleware/koa-log-session.ts index c65ac48cd..dcc2e7111 100644 --- a/packages/core/src/middleware/koa-log-session.ts +++ b/packages/core/src/middleware/koa-log-session.ts @@ -1,7 +1,7 @@ import type { MiddlewareType } from 'koa'; import type { Provider } from 'oidc-provider'; -import type { WithLogContext } from '@/middleware/koa-log'; +import type { WithLogContext } from '#src/middleware/koa-log.js'; export default function koaLogSession( provider: Provider diff --git a/packages/core/src/middleware/koa-log.test.ts b/packages/core/src/middleware/koa-log.test.ts index d596d4d87..cedf4346f 100644 --- a/packages/core/src/middleware/koa-log.test.ts +++ b/packages/core/src/middleware/koa-log.test.ts @@ -1,29 +1,31 @@ import type { LogPayload } from '@logto/schemas'; import { LogResult } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; import i18next from 'i18next'; -import RequestError from '@/errors/RequestError'; -import { insertLog } from '@/queries/log'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import type { WithLogContext } from './koa-log'; -import koaLog from './koa-log'; +import type { WithLogContext } from './koa-log.js'; + +const { jest } = import.meta; const nanoIdMock = 'mockId'; const addLogContext = jest.fn(); const log = jest.fn(); -jest.mock('@/queries/log', () => ({ - insertLog: jest.fn(async () => 0), +const { insertLog } = mockEsm('#src/queries/log.js', () => ({ + insertLog: jest.fn(), })); -jest.mock('nanoid', () => ({ - nanoid: jest.fn(() => nanoIdMock), +mockEsm('nanoid', () => ({ + nanoid: () => nanoIdMock, })); +const koaLog = await pickDefault(import('./koa-log.js')); + describe('koaLog middleware', () => { - const insertLogMock = insertLog as jest.Mock; const type = 'SignInUsernamePassword'; const mockPayload: LogPayload = { userId: 'foo', @@ -54,7 +56,7 @@ describe('koaLog middleware', () => { }; await koaLog()(ctx, next); - expect(insertLogMock).toBeCalledWith({ + expect(insertLog).toBeCalledWith({ id: nanoIdMock, type, payload: { @@ -79,7 +81,7 @@ describe('koaLog middleware', () => { // eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function const next = async () => {}; await koaLog()(ctx, next); - expect(insertLogMock).not.toBeCalled(); + expect(insertLog).not.toBeCalled(); }); describe('should insert an error log with the error message when next() throws an error', () => { @@ -101,7 +103,7 @@ describe('koaLog middleware', () => { }; await expect(koaLog()(ctx, next)).rejects.toMatchError(error); - expect(insertLogMock).toBeCalledWith({ + expect(insertLog).toBeCalledWith({ id: nanoIdMock, type, payload: { @@ -135,7 +137,7 @@ describe('koaLog middleware', () => { }; await expect(koaLog()(ctx, next)).rejects.toMatchError(error); - expect(insertLogMock).toBeCalledWith({ + expect(insertLog).toBeCalledWith({ id: nanoIdMock, type, payload: { diff --git a/packages/core/src/middleware/koa-log.ts b/packages/core/src/middleware/koa-log.ts index e5c88ef9d..5c406f624 100644 --- a/packages/core/src/middleware/koa-log.ts +++ b/packages/core/src/middleware/koa-log.ts @@ -6,8 +6,8 @@ import type { IRouterParamContext } from 'koa-router'; import pick from 'lodash.pick'; import { nanoid } from 'nanoid'; -import RequestError from '@/errors/RequestError'; -import { insertLog } from '@/queries/log'; +import RequestError from '#src/errors/RequestError/index.js'; +import { insertLog } from '#src/queries/log.js'; type MergeLog = (type: T, payload: LogPayloads[T]) => void; diff --git a/packages/core/src/middleware/koa-oidc-error-handler.test.ts b/packages/core/src/middleware/koa-oidc-error-handler.test.ts index 632518083..0dcd79460 100644 --- a/packages/core/src/middleware/koa-oidc-error-handler.test.ts +++ b/packages/core/src/middleware/koa-oidc-error-handler.test.ts @@ -1,9 +1,11 @@ import { errors } from 'oidc-provider'; -import RequestError from '@/errors/RequestError'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaOIDCErrorHandler from './koa-oidc-error-handler'; +import koaOIDCErrorHandler from './koa-oidc-error-handler.js'; + +const { jest } = import.meta; describe('koaOIDCErrorHandler middleware', () => { const next = jest.fn(); diff --git a/packages/core/src/middleware/koa-oidc-error-handler.ts b/packages/core/src/middleware/koa-oidc-error-handler.ts index 9d3859d2a..6182dbf37 100644 --- a/packages/core/src/middleware/koa-oidc-error-handler.ts +++ b/packages/core/src/middleware/koa-oidc-error-handler.ts @@ -3,7 +3,7 @@ import decamelize from 'decamelize'; import type { Middleware } from 'koa'; import { errors } from 'oidc-provider'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; /** * OIDC Provider Error Definition: https://github.com/panva/node-oidc-provider/blob/main/lib/helpers/errors.js diff --git a/packages/core/src/middleware/koa-pagination.test.ts b/packages/core/src/middleware/koa-pagination.test.ts index d4f931f5f..3f7da06ad 100644 --- a/packages/core/src/middleware/koa-pagination.test.ts +++ b/packages/core/src/middleware/koa-pagination.test.ts @@ -1,8 +1,11 @@ -import { createMockContext } from '@shopify/jest-koa-mocks'; import type { Context } from 'koa'; -import type { WithPaginationContext } from './koa-pagination'; -import koaPagination from './koa-pagination'; +import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; + +import type { WithPaginationContext } from './koa-pagination.js'; +import koaPagination from './koa-pagination.js'; + +const { jest } = import.meta; const next = jest.fn(); const setHeader = jest.fn(); diff --git a/packages/core/src/middleware/koa-pagination.ts b/packages/core/src/middleware/koa-pagination.ts index 0a2878072..f2d4b1bbb 100644 --- a/packages/core/src/middleware/koa-pagination.ts +++ b/packages/core/src/middleware/koa-pagination.ts @@ -2,8 +2,8 @@ import type { MiddlewareType } from 'koa'; import type { IMiddleware } from 'koa-router'; import { number } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { buildLink } from '@/utils/pagination'; +import RequestError from '#src/errors/RequestError/index.js'; +import { buildLink } from '#src/utils/pagination.js'; export type Pagination = { offset: number; diff --git a/packages/core/src/middleware/koa-root-proxy.test.ts b/packages/core/src/middleware/koa-root-proxy.test.ts index f98a4e4f3..7105161e0 100644 --- a/packages/core/src/middleware/koa-root-proxy.test.ts +++ b/packages/core/src/middleware/koa-root-proxy.test.ts @@ -1,6 +1,8 @@ -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaRootProxy from './koa-root-proxy'; +import koaRootProxy from './koa-root-proxy.js'; + +const { jest } = import.meta; describe('koaRootProxy', () => { const next = jest.fn(); diff --git a/packages/core/src/middleware/koa-root-proxy.ts b/packages/core/src/middleware/koa-root-proxy.ts index 2034722b2..1ee55fbce 100644 --- a/packages/core/src/middleware/koa-root-proxy.ts +++ b/packages/core/src/middleware/koa-root-proxy.ts @@ -1,8 +1,8 @@ import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '@/env-set'; -import { appendPath } from '@/utils/url'; +import envSet from '#src/env-set/index.js'; +import { appendPath } from '#src/utils/url.js'; export default function koaRootProxy< StateT, diff --git a/packages/core/src/middleware/koa-serve-static.ts b/packages/core/src/middleware/koa-serve-static.ts index 62b4ddf67..724653a2f 100644 --- a/packages/core/src/middleware/koa-serve-static.ts +++ b/packages/core/src/middleware/koa-serve-static.ts @@ -6,7 +6,7 @@ import buildDebug from 'debug'; import type { MiddlewareType } from 'koa'; import send from 'koa-send'; -import assertThat from '@/utils/assert-that'; +import assertThat from '#src/utils/assert-that.js'; const debug = buildDebug('koa-static'); diff --git a/packages/core/src/middleware/koa-slonik-error-handler.test.ts b/packages/core/src/middleware/koa-slonik-error-handler.test.ts index 321a13efc..6c189a4a2 100644 --- a/packages/core/src/middleware/koa-slonik-error-handler.test.ts +++ b/packages/core/src/middleware/koa-slonik-error-handler.test.ts @@ -1,11 +1,13 @@ import { Users } from '@logto/schemas'; import { NotFoundError, SlonikError } from 'slonik'; -import RequestError from '@/errors/RequestError'; -import { DeletionError, InsertionError, UpdateError } from '@/errors/SlonikError'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import RequestError from '#src/errors/RequestError/index.js'; +import { DeletionError, InsertionError, UpdateError } from '#src/errors/SlonikError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaSlonikErrorHandler from './koa-slonik-error-handler'; +import koaSlonikErrorHandler from './koa-slonik-error-handler.js'; + +const { jest } = import.meta; describe('koaSlonikErrorHandler middleware', () => { const next = jest.fn(); diff --git a/packages/core/src/middleware/koa-slonik-error-handler.ts b/packages/core/src/middleware/koa-slonik-error-handler.ts index 961f6901c..82aa632dc 100644 --- a/packages/core/src/middleware/koa-slonik-error-handler.ts +++ b/packages/core/src/middleware/koa-slonik-error-handler.ts @@ -23,8 +23,8 @@ import type { SchemaLike } from '@logto/schemas'; import type { Middleware } from 'koa'; import { SlonikError, NotFoundError } from 'slonik'; -import RequestError from '@/errors/RequestError'; -import { DeletionError, InsertionError, UpdateError } from '@/errors/SlonikError'; +import RequestError from '#src/errors/RequestError/index.js'; +import { DeletionError, InsertionError, UpdateError } from '#src/errors/SlonikError/index.js'; export default function koaSlonikErrorHandler(): Middleware { return async (ctx, next) => { diff --git a/packages/core/src/middleware/koa-spa-proxy.test.ts b/packages/core/src/middleware/koa-spa-proxy.test.ts index 2ea39ead5..e84cc7845 100644 --- a/packages/core/src/middleware/koa-spa-proxy.test.ts +++ b/packages/core/src/middleware/koa-spa-proxy.test.ts @@ -1,18 +1,21 @@ -import envSet, { MountedApps } from '@/env-set'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import { mockEsmDefault, pickDefault } from '@logto/shared/esm'; -import koaSpaProxy from './koa-spa-proxy'; +import envSet, { MountedApps } from '#src/env-set/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; const mockProxyMiddleware = jest.fn(); const mockStaticMiddleware = jest.fn(); -jest.mock('fs/promises', () => ({ - ...jest.requireActual('fs/promises'), +mockEsmDefault('fs/promises', () => ({ readdir: jest.fn().mockResolvedValue(['sign-in']), })); -jest.mock('koa-proxies', () => jest.fn(() => mockProxyMiddleware)); -jest.mock('@/middleware/koa-serve-static', () => jest.fn(() => mockStaticMiddleware)); +mockEsmDefault('koa-proxies', () => jest.fn(() => mockProxyMiddleware)); +mockEsmDefault('#src/middleware/koa-serve-static.js', () => jest.fn(() => mockStaticMiddleware)); + +const koaSpaProxy = await pickDefault(import('./koa-spa-proxy.js')); describe('koaSpaProxy middleware', () => { const envBackup = process.env; diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts index 4eb3e4f97..87e343631 100644 --- a/packages/core/src/middleware/koa-spa-proxy.ts +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -5,8 +5,8 @@ import type { MiddlewareType } from 'koa'; import proxy from 'koa-proxies'; import type { IRouterParamContext } from 'koa-router'; -import envSet, { MountedApps } from '@/env-set'; -import serveStatic from '@/middleware/koa-serve-static'; +import envSet, { MountedApps } from '#src/env-set/index.js'; +import serveStatic from '#src/middleware/koa-serve-static.js'; export default function koaSpaProxy( packagePath = 'ui', diff --git a/packages/core/src/middleware/koa-spa-session-guard.test.ts b/packages/core/src/middleware/koa-spa-session-guard.test.ts index 0726cbcb0..f80c22475 100644 --- a/packages/core/src/middleware/koa-spa-session-guard.test.ts +++ b/packages/core/src/middleware/koa-spa-session-guard.test.ts @@ -1,23 +1,25 @@ +import { mockEsmWithActual } from '@logto/shared/esm'; import { Provider } from 'oidc-provider'; -import { MountedApps } from '@/env-set'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import { MountedApps } from '#src/env-set/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -import koaSpaSessionGuard, { sessionNotFoundPath, guardedPath } from './koa-spa-session-guard'; +const { jest } = import.meta; -jest.mock('fs/promises', () => ({ - ...jest.requireActual('fs/promises'), +await mockEsmWithActual('fs/promises', () => ({ readdir: jest.fn().mockResolvedValue(['index.js']), })); -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails: jest.fn(), - })), -})); +const { + default: koaSpaSessionGuard, + sessionNotFoundPath, + guardedPath, +} = await import('./koa-spa-session-guard.js'); describe('koaSpaSessionGuard', () => { const envBackup = process.env; + const provider = new Provider('https://logto.test'); + const interactionDetails = jest.spyOn(provider, 'interactionDetails'); beforeEach(() => { process.env = { ...envBackup }; @@ -33,7 +35,6 @@ describe('koaSpaSessionGuard', () => { for (const app of Object.values(MountedApps)) { // eslint-disable-next-line @typescript-eslint/no-loop-func it(`${app} path should not redirect`, async () => { - const provider = new Provider(''); const ctx = createContextWithRouteParameters({ url: `/${app}/foo`, }); @@ -45,9 +46,7 @@ describe('koaSpaSessionGuard', () => { } it(`should not redirect for path ${sessionNotFoundPath}`, async () => { - const provider = new Provider(''); - - (provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found')); + interactionDetails.mockRejectedValue(new Error('session not found')); const ctx = createContextWithRouteParameters({ url: `${sessionNotFoundPath}`, }); @@ -56,9 +55,7 @@ describe('koaSpaSessionGuard', () => { }); it(`should not redirect for path /callback`, async () => { - const provider = new Provider(''); - - (provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found')); + interactionDetails.mockRejectedValue(new Error('session not found')); const ctx = createContextWithRouteParameters({ url: '/callback/github', }); @@ -67,7 +64,8 @@ describe('koaSpaSessionGuard', () => { }); it('should not redirect if session found', async () => { - const provider = new Provider(''); + // @ts-expect-error for testing + interactionDetails.mockResolvedValue({}); const ctx = createContextWithRouteParameters({ url: `/sign-in`, }); @@ -78,9 +76,7 @@ describe('koaSpaSessionGuard', () => { for (const path of guardedPath) { // eslint-disable-next-line @typescript-eslint/no-loop-func it(`should redirect if session not found for ${path}`, async () => { - const provider = new Provider(''); - - (provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found')); + interactionDetails.mockRejectedValue(new Error('session not found')); const ctx = createContextWithRouteParameters({ url: `${path}/foo`, }); diff --git a/packages/core/src/middleware/koa-spa-session-guard.ts b/packages/core/src/middleware/koa-spa-session-guard.ts index dbe66c796..c234279bc 100644 --- a/packages/core/src/middleware/koa-spa-session-guard.ts +++ b/packages/core/src/middleware/koa-spa-session-guard.ts @@ -2,8 +2,8 @@ import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; import type { Provider } from 'oidc-provider'; -import envSet from '@/env-set'; -import { appendPath } from '@/utils/url'; +import envSet from '#src/env-set/index.js'; +import { appendPath } from '#src/utils/url.js'; // Need To Align With UI export const sessionNotFoundPath = '/unknown-session'; diff --git a/packages/core/src/middleware/koa-welcome-proxy.test.ts b/packages/core/src/middleware/koa-welcome-proxy.test.ts index c709af743..5750416aa 100644 --- a/packages/core/src/middleware/koa-welcome-proxy.test.ts +++ b/packages/core/src/middleware/koa-welcome-proxy.test.ts @@ -1,13 +1,16 @@ -import envSet, { MountedApps } from '@/env-set'; -import { hasActiveUsers } from '@/queries/user'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; -import koaWelcomeProxy from './koa-welcome-proxy'; +import envSet, { MountedApps } from '#src/env-set/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; -jest.mock('@/queries/user', () => ({ +const { jest } = import.meta; + +const { hasActiveUsers } = mockEsm('#src/queries/user.js', () => ({ hasActiveUsers: jest.fn(), })); +const koaWelcomeProxy = await pickDefault(import('./koa-welcome-proxy.js')); + describe('koaWelcomeProxy', () => { const next = jest.fn(); @@ -18,7 +21,7 @@ describe('koaWelcomeProxy', () => { it('should redirect to admin console if has AdminUsers', async () => { const { endpoint } = envSet.values; - (hasActiveUsers as jest.Mock).mockResolvedValue(true); + hasActiveUsers.mockResolvedValue(true); const ctx = createContextWithRouteParameters({ url: `/${MountedApps.Welcome}`, }); @@ -31,7 +34,7 @@ describe('koaWelcomeProxy', () => { it('should redirect to welcome page if has no Users', async () => { const { endpoint } = envSet.values; - (hasActiveUsers as jest.Mock).mockResolvedValue(false); + hasActiveUsers.mockResolvedValue(false); const ctx = createContextWithRouteParameters({ url: `/${MountedApps.Welcome}`, }); diff --git a/packages/core/src/middleware/koa-welcome-proxy.ts b/packages/core/src/middleware/koa-welcome-proxy.ts index 751535e5e..b338eb2de 100644 --- a/packages/core/src/middleware/koa-welcome-proxy.ts +++ b/packages/core/src/middleware/koa-welcome-proxy.ts @@ -1,9 +1,9 @@ import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '@/env-set'; -import { hasActiveUsers } from '@/queries/user'; -import { appendPath } from '@/utils/url'; +import envSet from '#src/env-set/index.js'; +import { hasActiveUsers } from '#src/queries/user.js'; +import { appendPath } from '#src/utils/url.js'; export default function koaWelcomeProxy< StateT, diff --git a/packages/core/src/oidc/adapter.test.ts b/packages/core/src/oidc/adapter.test.ts index c5ad29faa..7d9f24c81 100644 --- a/packages/core/src/oidc/adapter.test.ts +++ b/packages/core/src/oidc/adapter.test.ts @@ -1,24 +1,18 @@ import type { Application } from '@logto/schemas'; +import { mockEsm } from '@logto/shared/esm'; import snakecaseKeys from 'snakecase-keys'; -import { mockApplication } from '@/__mocks__'; -import { - consumeInstanceById, - destroyInstanceById, - findPayloadById, - findPayloadByPayloadField, - revokeInstanceByGrantId, - upsertInstance, -} from '@/queries/oidc-model-instance'; +import { mockApplication } from '#src/__mocks__/index.js'; -import postgresAdapter from './adapter'; -import { getConstantClientMetadata } from './utils'; +import { getConstantClientMetadata } from './utils.js'; -jest.mock('@/queries/application', () => ({ +const { jest } = import.meta; + +mockEsm('#src/queries/application.js', () => ({ findApplicationById: jest.fn(async (): Promise => mockApplication), })); -jest.mock('@/queries/oidc-model-instance', () => ({ +mockEsm('#src/queries/oidc-model-instance.js', () => ({ upsertInstance: jest.fn(), findPayloadById: jest.fn(), findPayloadByPayloadField: jest.fn(), @@ -27,15 +21,25 @@ jest.mock('@/queries/oidc-model-instance', () => ({ revokeInstanceByGrantId: jest.fn(), })); -const now = Date.now(); - -jest.mock( +mockEsm( 'date-fns', jest.fn(() => ({ addSeconds: jest.fn((_: Date, seconds: number) => new Date(now + seconds * 1000)), })) ); +const { default: postgresAdapter } = await import('./adapter.js'); +const { + consumeInstanceById, + destroyInstanceById, + findPayloadById, + findPayloadByPayloadField, + revokeInstanceByGrantId, + upsertInstance, +} = await import('#src/queries/oidc-model-instance.js'); + +const now = Date.now(); + describe('postgres Adapter', () => { it('Client Modal', async () => { const rejectError = new Error('Not implemented'); diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index 9dd988cb1..8e3fa5ed7 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -1,14 +1,15 @@ import type { CreateApplication, OidcClientMetadata } from '@logto/schemas'; import { ApplicationType } from '@logto/schemas'; -import { adminConsoleApplicationId, demoAppApplicationId } from '@logto/schemas/lib/seeds'; +import { adminConsoleApplicationId, demoAppApplicationId } from '@logto/schemas/lib/seeds/index.js'; import { tryThat } from '@logto/shared'; +import { deduplicate } from '@silverhand/essentials'; import { addSeconds } from 'date-fns'; import type { AdapterFactory, AllClientMetadata } from 'oidc-provider'; import { errors } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; -import envSet, { MountedApps } from '@/env-set'; -import { findApplicationById } from '@/queries/application'; +import envSet, { MountedApps } from '#src/env-set/index.js'; +import { findApplicationById } from '#src/queries/application.js'; import { consumeInstanceById, destroyInstanceById, @@ -16,16 +17,17 @@ import { findPayloadByPayloadField, revokeInstanceByGrantId, upsertInstance, -} from '@/queries/oidc-model-instance'; -import { appendPath } from '@/utils/url'; +} from '#src/queries/oidc-model-instance.js'; +import { appendPath } from '#src/utils/url.js'; -import { getConstantClientMetadata } from './utils'; +import { getConstantClientMetadata } from './utils.js'; const buildAdminConsoleClientMetadata = (): AllClientMetadata => { const { localhostUrl, adminConsoleUrl } = envSet.values; - const urls = [ - ...new Set([appendPath(localhostUrl, '/console').toString(), adminConsoleUrl.toString()]), - ]; + const urls = deduplicate([ + appendPath(localhostUrl, '/console').toString(), + adminConsoleUrl.toString(), + ]); return { ...getConstantClientMetadata(ApplicationType.SPA), @@ -46,8 +48,8 @@ const buildDemoAppUris = ( ]; const data = { - redirectUris: [...new Set([...urls, ...oidcClientMetadata.redirectUris])], - postLogoutRedirectUris: [...new Set([...urls, ...oidcClientMetadata.postLogoutRedirectUris])], + redirectUris: deduplicate([...urls, ...oidcClientMetadata.redirectUris]), + postLogoutRedirectUris: deduplicate([...urls, ...oidcClientMetadata.postLogoutRedirectUris]), }; return data; diff --git a/packages/core/src/oidc/init.test.ts b/packages/core/src/oidc/init.test.ts index 909738be6..6973dd129 100644 --- a/packages/core/src/oidc/init.test.ts +++ b/packages/core/src/oidc/init.test.ts @@ -1,6 +1,6 @@ import Koa from 'koa'; -import initOidc from './init'; +import initOidc from './init.js'; describe('oidc provider init', () => { it('init should not throw', async () => { diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 0ad32502f..25ac84417 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -10,17 +10,17 @@ import mount from 'koa-mount'; import { Provider, errors } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; -import envSet from '@/env-set'; -import postgresAdapter from '@/oidc/adapter'; -import { isOriginAllowed, validateCustomClientMetadata } from '@/oidc/utils'; -import { findApplicationById } from '@/queries/application'; -import { findResourceByIndicator } from '@/queries/resource'; -import { findUserById } from '@/queries/user'; -import { routes } from '@/routes/consts'; -import assertThat from '@/utils/assert-that'; -import { addOidcEventListeners } from '@/utils/oidc-provider-event-listener'; +import envSet from '#src/env-set/index.js'; +import postgresAdapter from '#src/oidc/adapter.js'; +import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js'; +import { findApplicationById } from '#src/queries/application.js'; +import { findResourceByIndicator } from '#src/queries/resource.js'; +import { findUserById } from '#src/queries/user.js'; +import { routes } from '#src/routes/consts.js'; +import assertThat from '#src/utils/assert-that.js'; +import { addOidcEventListeners } from '#src/utils/oidc-provider-event-listener.js'; -import { claimToUserKey, getUserClaims } from './scope'; +import { claimToUserKey, getUserClaims } from './scope.js'; export default async function initOidc(app: Koa): Promise { const { issuer, cookieKeys, privateJwks, defaultIdTokenTtl, defaultRefreshTokenTtl } = diff --git a/packages/core/src/oidc/scope.test.ts b/packages/core/src/oidc/scope.test.ts index 14bd7afde..e2cac7ee4 100644 --- a/packages/core/src/oidc/scope.test.ts +++ b/packages/core/src/oidc/scope.test.ts @@ -1,4 +1,4 @@ -import { getUserClaims } from './scope'; +import { getUserClaims } from './scope.js'; const use = { idToken: 'id_token', diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index 7e403dd89..1e5f5c5a2 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -5,7 +5,7 @@ import { buildOidcClientMetadata, getConstantClientMetadata, validateCustomClientMetadata, -} from './utils'; +} from './utils.js'; describe('getConstantClientMetadata()', () => { expect(getConstantClientMetadata(ApplicationType.SPA)).toEqual({ diff --git a/packages/core/src/queries/application.test.ts b/packages/core/src/queries/application.test.ts index e9c7baf78..87fa00b5e 100644 --- a/packages/core/src/queries/application.test.ts +++ b/packages/core/src/queries/application.test.ts @@ -3,11 +3,11 @@ import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } f import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { snakeCase } from 'snake-case'; -import { mockApplication } from '@/__mocks__'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockApplication } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; import { findTotalNumberOfApplications, @@ -16,8 +16,9 @@ import { insertApplication, updateApplicationById, deleteApplicationById, -} from './application'; +} from './application.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index afeb353f6..6956ebdb7 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -4,12 +4,12 @@ import type { OmitAutoSetFields } from '@logto/shared'; import { convertToIdentifiers, conditionalSql, manyRows } from '@logto/shared'; import { sql } from 'slonik'; -import { buildFindEntityById } from '@/database/find-entity-by-id'; -import { buildInsertInto } from '@/database/insert-into'; -import { getTotalRowCount } from '@/database/row-count'; -import { buildUpdateWhere } from '@/database/update-where'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; +import { buildFindEntityById } from '#src/database/find-entity-by-id.js'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import { getTotalRowCount } from '#src/database/row-count.js'; +import { buildUpdateWhere } from '#src/database/update-where.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Applications); diff --git a/packages/core/src/queries/connector.test.ts b/packages/core/src/queries/connector.test.ts index bee581128..05b3169b9 100644 --- a/packages/core/src/queries/connector.test.ts +++ b/packages/core/src/queries/connector.test.ts @@ -2,13 +2,23 @@ import { Connectors } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; -import { mockConnector } from '@/__mocks__'; -import envSet from '@/env-set'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockConnector } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; -import { findAllConnectors, insertConnector, updateConnector } from './connector'; +import { + findAllConnectors, + findConnectorById, + countConnectorByConnectorId, + deleteConnectorById, + deleteConnectorByIds, + insertConnector, + updateConnector, +} from './connector.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( @@ -27,7 +37,7 @@ describe('connector queries', () => { const expectSql = sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} - order by ${fields.enabled} desc, ${fields.id} asc + order by ${fields.id} asc `; mockQuery.mockImplementationOnce(async (sql, values) => { @@ -40,22 +50,144 @@ describe('connector queries', () => { await expect(findAllConnectors()).resolves.toEqual([rowData]); }); + it('findConnectorById', async () => { + const row = { + ...mockConnector, + config: JSON.stringify(mockConnector.config), + metadata: JSON.stringify(mockConnector.metadata), + }; + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where ${fields.id}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([mockConnector.id]); + + return createMockQueryResult([row]); + }); + + await expect(findConnectorById(mockConnector.id)).resolves.toEqual(row); + }); + + it('countConnectorsByConnectorId', async () => { + const rowData = { id: 'foo', connectorId: 'bar' }; + + const expectSql = sql` + select count(*) + from ${table} + where ${fields.connectorId}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual(['bar']); + + return createMockQueryResult([rowData]); + }); + + await expect(countConnectorByConnectorId(rowData.connectorId)).resolves.toEqual(rowData); + }); + + it('deleteConnectorById', async () => { + const rowData = { id: 'foo' }; + const id = 'foo'; + const expectSql = sql` + delete from ${table} + where ${fields.id}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([id]); + + return createMockQueryResult([rowData]); + }); + + await deleteConnectorById(id); + }); + + it('deleteConnectorById should throw with zero response', async () => { + const id = 'foo'; + const expectSql = sql` + delete from ${table} + where ${fields.id}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([id]); + + return createMockQueryResult([]); + }); + + await expect(deleteConnectorById(id)).rejects.toMatchError( + new DeletionError(Connectors.table, id) + ); + }); + + it('deleteConnectorByIds', async () => { + const rowData = [{ id: 'foo' }, { id: 'bar' }, { id: 'baz' }]; + const ids = ['foo', 'bar', 'baz']; + const expectSql = sql` + delete from ${table} + where ${fields.id} in ($1, $2, $3) + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual(ids); + + return createMockQueryResult(rowData); + }); + + await deleteConnectorByIds(ids); + }); + + it('deleteConnectorByIds should throw with row count does not match length of ids', async () => { + const ids = ['foo', 'bar', 'baz']; + const expectSql = sql` + delete from ${table} + where ${fields.id} in ($1, $2, $3) + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual(ids); + + return createMockQueryResult([{ id: 'foo' }, { id: 'bar' }]); + }); + + await expect(deleteConnectorByIds(ids)).rejects.toMatchError( + new DeletionError(Connectors.table, JSON.stringify({ ids })) + ); + }); + it('insertConnector', async () => { const connector = { ...mockConnector, config: JSON.stringify(mockConnector.config), + metadata: JSON.stringify(mockConnector.metadata), }; const expectSql = ` - insert into "connectors" ("id", "enabled", "config") - values ($1, $2, $3) + insert into "connectors" ("id", "sync_profile", "connector_id", "config", "metadata") + values ($1, $2, $3, $4, $5) returning * `; mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql); - expect(values).toEqual([connector.id, connector.enabled, connector.config]); + expect(values).toEqual([ + connector.id, + connector.syncProfile, + connector.connectorId, + connector.config, + connector.metadata, + ]); return createMockQueryResult([connector]); }); @@ -65,27 +197,27 @@ describe('connector queries', () => { it('updateConnector (with id)', async () => { const id = 'foo'; - const enabled = false; + const syncProfile = false; const expectSql = sql` update ${table} - set ${fields.enabled}=$1 + set ${fields.syncProfile}=$1 where ${fields.id}=$2 returning * `; mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual([enabled, id]); + expect(values).toEqual([syncProfile, id]); - return createMockQueryResult([{ id, enabled }]); + return createMockQueryResult([{ id, syncProfile }]); }); await expect( - updateConnector({ where: { id }, set: { enabled }, jsonbMode: 'merge' }) + updateConnector({ where: { id }, set: { syncProfile }, jsonbMode: 'merge' }) ).resolves.toEqual({ id, - enabled, + syncProfile, }); }); }); diff --git a/packages/core/src/queries/connector.ts b/packages/core/src/queries/connector.ts index 7cd81895b..32ab5b3f2 100644 --- a/packages/core/src/queries/connector.ts +++ b/packages/core/src/queries/connector.ts @@ -1,11 +1,12 @@ import type { Connector, CreateConnector } from '@logto/schemas'; import { Connectors } from '@logto/schemas'; -import { convertToIdentifiers, manyRows } from '@logto/shared'; +import { manyRows, convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; -import { buildInsertInto } from '@/database/insert-into'; -import { buildUpdateWhere } from '@/database/update-where'; -import envSet from '@/env-set'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import { buildUpdateWhere } from '#src/database/update-where.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Connectors); @@ -14,10 +15,46 @@ export const findAllConnectors = async () => envSet.pool.query(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} - order by ${fields.enabled} desc, ${fields.id} asc + order by ${fields.id} asc `) ); +export const findConnectorById = async (id: string) => + envSet.pool.one(sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where ${fields.id}=${id} + `); + +export const countConnectorByConnectorId = async (connectorId: string) => + envSet.pool.one<{ count: number }>(sql` + select count(*) + from ${table} + where ${fields.connectorId}=${connectorId} + `); + +export const deleteConnectorById = async (id: string) => { + const { rowCount } = await envSet.pool.query(sql` + delete from ${table} + where ${fields.id}=${id} + `); + + if (rowCount < 1) { + throw new DeletionError(Connectors.table, id); + } +}; + +export const deleteConnectorByIds = async (ids: string[]) => { + const { rowCount } = await envSet.pool.query(sql` + delete from ${table} + where ${fields.id} in (${sql.join(ids, sql`, `)}) + `); + + if (rowCount !== ids.length) { + throw new DeletionError(Connectors.table, JSON.stringify({ ids })); + } +}; + export const insertConnector = buildInsertInto(Connectors, { returning: true, }); diff --git a/packages/core/src/queries/custom-phrase.ts b/packages/core/src/queries/custom-phrase.ts index 947e78758..2abb510cd 100644 --- a/packages/core/src/queries/custom-phrase.ts +++ b/packages/core/src/queries/custom-phrase.ts @@ -3,9 +3,9 @@ import { CustomPhrases } from '@logto/schemas'; import { convertToIdentifiers, manyRows } from '@logto/shared'; import { sql } from 'slonik'; -import { buildInsertInto } from '@/database/insert-into'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(CustomPhrases); diff --git a/packages/core/src/queries/log.ts b/packages/core/src/queries/log.ts index 9a4a3fc25..623027ec9 100644 --- a/packages/core/src/queries/log.ts +++ b/packages/core/src/queries/log.ts @@ -3,9 +3,9 @@ import { Logs } from '@logto/schemas'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; -import { buildFindEntityById } from '@/database/find-entity-by-id'; -import { buildInsertInto } from '@/database/insert-into'; -import envSet from '@/env-set'; +import { buildFindEntityById } from '#src/database/find-entity-by-id.js'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import envSet from '#src/env-set/index.js'; const { table, fields } = convertToIdentifiers(Logs); diff --git a/packages/core/src/queries/oidc-model-instance.test.ts b/packages/core/src/queries/oidc-model-instance.test.ts index 499f9ff85..3d6366a45 100644 --- a/packages/core/src/queries/oidc-model-instance.test.ts +++ b/packages/core/src/queries/oidc-model-instance.test.ts @@ -1,21 +1,14 @@ import type { CreateOidcModelInstance } from '@logto/schemas'; import { OidcModelInstances } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; +import { mockEsmWithActual } from '@logto/shared/esm'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; -import envSet from '@/env-set'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; - -import { - upsertInstance, - findPayloadById, - findPayloadByPayloadField, - consumeInstanceById, - destroyInstanceById, - revokeInstanceByGrantId, -} from './oidc-model-instance'; +import envSet from '#src/env-set/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( @@ -26,11 +19,19 @@ jest.spyOn(envSet, 'pool', 'get').mockReturnValue( }) ); -jest.mock('@logto/shared', () => ({ - ...jest.requireActual('@logto/shared'), +await mockEsmWithActual('@logto/shared', () => ({ convertToTimestamp: () => 100, })); +const { + upsertInstance, + findPayloadById, + findPayloadByPayloadField, + consumeInstanceById, + destroyInstanceById, + revokeInstanceByGrantId, +} = await import('./oidc-model-instance.js'); + describe('oidc-model-instance query', () => { const { table, fields } = convertToIdentifiers(OidcModelInstances); const expiresAt = Date.now(); diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index a2c660309..d903ff603 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -11,8 +11,8 @@ import { addSeconds, isBefore } from 'date-fns'; import type { ValueExpression } from 'slonik'; import { sql } from 'slonik'; -import { buildInsertInto } from '@/database/insert-into'; -import envSet from '@/env-set'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import envSet from '#src/env-set/index.js'; export type WithConsumed = T & { consumed?: boolean }; export type QueryResult = Pick; diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts index 4b833a5b9..283e1aee1 100644 --- a/packages/core/src/queries/passcode.test.ts +++ b/packages/core/src/queries/passcode.test.ts @@ -3,11 +3,11 @@ import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } f import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { snakeCase } from 'snake-case'; -import { mockPasscode } from '@/__mocks__'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockPasscode } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; import { findUnconsumedPasscodeByJtiAndType, @@ -15,8 +15,9 @@ import { insertPasscode, deletePasscodeById, deletePasscodesByIds, -} from './passcode'; +} from './passcode.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts index eda2b5d4b..21431888b 100644 --- a/packages/core/src/queries/passcode.ts +++ b/packages/core/src/queries/passcode.ts @@ -3,9 +3,9 @@ import { Passcodes } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; -import { buildInsertInto } from '@/database/insert-into'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Passcodes); diff --git a/packages/core/src/queries/resource.test.ts b/packages/core/src/queries/resource.test.ts index 2db43da2c..20d3cd0f8 100644 --- a/packages/core/src/queries/resource.test.ts +++ b/packages/core/src/queries/resource.test.ts @@ -2,11 +2,11 @@ import { Resources } from '@logto/schemas'; import { convertToIdentifiers, convertToPrimitiveOrSql } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; -import { mockResource } from '@/__mocks__'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockResource } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; import { findTotalNumberOfResources, @@ -16,8 +16,9 @@ import { insertResource, updateResourceById, deleteResourceById, -} from './resource'; +} from './resource.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( diff --git a/packages/core/src/queries/resource.ts b/packages/core/src/queries/resource.ts index e4ec927f2..7e6c94bfe 100644 --- a/packages/core/src/queries/resource.ts +++ b/packages/core/src/queries/resource.ts @@ -4,12 +4,12 @@ import type { OmitAutoSetFields } from '@logto/shared'; import { convertToIdentifiers, conditionalSql, manyRows } from '@logto/shared'; import { sql } from 'slonik'; -import { buildFindEntityById } from '@/database/find-entity-by-id'; -import { buildInsertInto } from '@/database/insert-into'; -import { getTotalRowCount } from '@/database/row-count'; -import { buildUpdateWhere } from '@/database/update-where'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; +import { buildFindEntityById } from '#src/database/find-entity-by-id.js'; +import { buildInsertInto } from '#src/database/insert-into.js'; +import { getTotalRowCount } from '#src/database/row-count.js'; +import { buildUpdateWhere } from '#src/database/update-where.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Resources); diff --git a/packages/core/src/queries/roles.test.ts b/packages/core/src/queries/roles.test.ts index 0351c9025..b024b3bb9 100644 --- a/packages/core/src/queries/roles.test.ts +++ b/packages/core/src/queries/roles.test.ts @@ -2,13 +2,14 @@ import { Roles } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; -import { mockRole } from '@/__mocks__'; -import envSet from '@/env-set'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockRole } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; -import { findAllRoles, findRolesByRoleNames } from './roles'; +import { findAllRoles, findRolesByRoleNames } from './roles.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index bb62ca601..d989bde4a 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -3,7 +3,7 @@ import { Roles } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; -import envSet from '@/env-set'; +import envSet from '#src/env-set/index.js'; const { table, fields } = convertToIdentifiers(Roles); diff --git a/packages/core/src/queries/setting.test.ts b/packages/core/src/queries/setting.test.ts index 1748aeaa4..ef4a2e800 100644 --- a/packages/core/src/queries/setting.test.ts +++ b/packages/core/src/queries/setting.test.ts @@ -2,13 +2,14 @@ import { Settings } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; -import { mockSetting } from '@/__mocks__'; -import envSet from '@/env-set'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockSetting } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; -import { defaultSettingId, getSetting, updateSetting } from './setting'; +import { defaultSettingId, getSetting, updateSetting } from './setting.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( diff --git a/packages/core/src/queries/setting.ts b/packages/core/src/queries/setting.ts index a7e263a43..08d4a4ba4 100644 --- a/packages/core/src/queries/setting.ts +++ b/packages/core/src/queries/setting.ts @@ -2,8 +2,8 @@ import type { Setting, CreateSetting } from '@logto/schemas'; import { Settings } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; -import { buildFindEntityById } from '@/database/find-entity-by-id'; -import { buildUpdateWhere } from '@/database/update-where'; +import { buildFindEntityById } from '#src/database/find-entity-by-id.js'; +import { buildUpdateWhere } from '#src/database/update-where.js'; export const defaultSettingId = 'default'; diff --git a/packages/core/src/queries/sign-in-experience.test.ts b/packages/core/src/queries/sign-in-experience.test.ts index b0a221f76..f2977c95a 100644 --- a/packages/core/src/queries/sign-in-experience.test.ts +++ b/packages/core/src/queries/sign-in-experience.test.ts @@ -1,12 +1,16 @@ import { createMockPool, createMockQueryResult } from 'slonik'; -import { mockSignInExperience } from '@/__mocks__'; -import envSet from '@/env-set'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockSignInExperience } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; -import { findDefaultSignInExperience, updateDefaultSignInExperience } from './sign-in-experience'; +import { + findDefaultSignInExperience, + updateDefaultSignInExperience, +} from './sign-in-experience.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( diff --git a/packages/core/src/queries/sign-in-experience.ts b/packages/core/src/queries/sign-in-experience.ts index 7029d5a6d..b57c2d69d 100644 --- a/packages/core/src/queries/sign-in-experience.ts +++ b/packages/core/src/queries/sign-in-experience.ts @@ -1,8 +1,8 @@ import type { SignInExperience, CreateSignInExperience } from '@logto/schemas'; import { SignInExperiences } from '@logto/schemas'; -import { buildFindEntityById } from '@/database/find-entity-by-id'; -import { buildUpdateWhere } from '@/database/update-where'; +import { buildFindEntityById } from '#src/database/find-entity-by-id.js'; +import { buildUpdateWhere } from '#src/database/update-where.js'; const updateSignInExperience = buildUpdateWhere( SignInExperiences, diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index c2d925c9a..98db64fd1 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -2,11 +2,11 @@ import { UserRole, Users } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; -import { mockUser } from '@/__mocks__'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; -import type { QueryType } from '@/utils/test-utils'; -import { expectSqlAssert } from '@/utils/test-utils'; +import { mockUser } from '#src/__mocks__/index.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import type { QueryType } from '#src/utils/test-utils.js'; +import { expectSqlAssert } from '#src/utils/test-utils.js'; import { findUserByUsername, @@ -24,8 +24,9 @@ import { updateUserById, deleteUserById, deleteUserIdentity, -} from './user'; +} from './user.js'; +const { jest } = import.meta; const mockQuery: jest.MockedFunction = jest.fn(); jest.spyOn(envSet, 'pool', 'get').mockReturnValue( @@ -306,6 +307,7 @@ describe('user query', () => { where ${fields.primaryEmail} ilike $1 or ${fields.primaryPhone} ilike $2 or ${ fields.username } ilike $3 or ${fields.name} ilike $4 + order by "created_at" desc limit $5 offset $6 `; @@ -338,6 +340,7 @@ describe('user query', () => { and (${fields.primaryEmail} ilike $2 or ${fields.primaryPhone} ilike $3 or ${ fields.username } ilike $4 or ${fields.name} ilike $5) + order by "created_at" desc limit $6 offset $7 `; @@ -370,6 +373,7 @@ describe('user query', () => { where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${ fields.username } like $3 or ${fields.name} like $4 + order by "created_at" desc limit $5 offset $6 `; diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 8f0f06899..a53fb165b 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -4,9 +4,9 @@ import type { OmitAutoSetFields } from '@logto/shared'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql } from 'slonik'; -import { buildUpdateWhere } from '@/database/update-where'; -import envSet from '@/env-set'; -import { DeletionError } from '@/errors/SlonikError'; +import { buildUpdateWhere } from '#src/database/update-where.js'; +import envSet from '#src/env-set/index.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Users); @@ -39,7 +39,7 @@ export const findUserById = async (id: string) => `); export const findUserByIdentity = async (target: string, userId: string) => - envSet.pool.one( + envSet.pool.maybeOne( sql` select ${sql.join(Object.values(fields), sql`,`)} from ${table} @@ -145,6 +145,7 @@ export const findUsers = async ( select ${sql.join(Object.values(fields), sql`,`)} from ${table} ${buildUserConditions(search, hideAdminUser, isCaseSensitive)} + order by ${fields.createdAt} desc limit ${limit} offset ${offset} ` diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index fafbf1b4d..050cc3ab8 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -1,21 +1,17 @@ import type { CreateUser, Role, User } from '@logto/schemas'; -import { SignUpIdentifier, userInfoSelectFields } from '@logto/schemas'; +import { userInfoSelectFields } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import pick from 'lodash.pick'; -import { mockUser, mockUserList, mockUserListResponse, mockUserResponse } from '@/__mocks__'; -import { encryptUserPassword } from '@/lib/user'; -import { findRolesByRoleNames } from '@/queries/roles'; import { - hasUser, - findUserById, - updateUserById, - deleteUserIdentity, - deleteUserById, -} from '@/queries/user'; -import { createRequester } from '@/utils/test-utils'; - -import adminUserRoutes from './admin-user'; + mockUser, + mockUserList, + mockUserListResponse, + mockUserResponse, +} from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; +const { jest } = import.meta; const filterUsersWithSearch = (users: User[], search: string) => users.filter((user) => [user.username, user.primaryEmail, user.primaryPhone, user.name].some((value) => @@ -23,44 +19,44 @@ const filterUsersWithSearch = (users: User[], search: string) => ) ); -const mockFindDefaultSignInExperience = jest.fn(async () => ({ - signUp: { - identifier: SignUpIdentifier.None, - password: false, - verify: false, - }, -})); - -jest.mock('@/queries/sign-in-experience', () => ({ - findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()), +mockEsm('#src/queries/sign-in-experience.js', () => ({ + findDefaultSignInExperience: jest.fn(async () => ({ + signUp: { + identifiers: [], + password: false, + verify: false, + }, + })), })); const mockHasUser = jest.fn(async () => false); const mockHasUserWithEmail = jest.fn(async () => false); const mockHasUserWithPhone = jest.fn(async () => false); -jest.mock('@/queries/user', () => ({ - countUsers: jest.fn(async (search) => ({ - count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length, - })), - findUsers: jest.fn( - async (limit, offset, search): Promise => - search ? filterUsersWithSearch(mockUserList, search) : mockUserList - ), - findUserById: jest.fn(async (): Promise => mockUser), - hasUser: jest.fn(async () => mockHasUser()), - hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()), - hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()), - updateUserById: jest.fn( - async (_, data: Partial): Promise => ({ - ...mockUser, - ...data, - }) - ), - deleteUserById: jest.fn(), - deleteUserIdentity: jest.fn(), -})); -jest.mock('@/lib/user', () => ({ +const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } = + await mockEsmWithActual('#src/queries/user.js', () => ({ + countUsers: jest.fn(async (search) => ({ + count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length, + })), + findUsers: jest.fn( + async (limit, offset, search): Promise => + search ? filterUsersWithSearch(mockUserList, search) : mockUserList + ), + findUserById: jest.fn(async (): Promise => mockUser), + hasUser: jest.fn(async () => mockHasUser()), + hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()), + hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()), + updateUserById: jest.fn( + async (_, data: Partial): Promise => ({ + ...mockUser, + ...data, + }) + ), + deleteUserById: jest.fn(), + deleteUserIdentity: jest.fn(), + })); + +const { encryptUserPassword } = await mockEsmWithActual('#src/lib/user.js', () => ({ generateUserId: jest.fn(() => 'fooId'), encryptUserPassword: jest.fn(() => ({ passwordEncrypted: 'password', @@ -74,18 +70,18 @@ jest.mock('@/lib/user', () => ({ ), })); -jest.mock('@/queries/roles', () => ({ +const { findRolesByRoleNames } = mockEsm('#src/queries/roles.js', () => ({ findRolesByRoleNames: jest.fn( - async (): Promise => [{ name: 'admin', description: 'none' }] + async (): Promise => [{ id: 'role_id', name: 'admin', description: 'none' }] ), })); -const revokeInstanceByUserId = jest.fn(); -jest.mock('@/queries/oidc-model-instance', () => ({ - revokeInstanceByUserId: async (modelName: string, userId: string) => - revokeInstanceByUserId(modelName, userId), +const { revokeInstanceByUserId } = mockEsm('#src/queries/oidc-model-instance.js', () => ({ + revokeInstanceByUserId: jest.fn(), })); +const adminUserRoutes = await pickDefault(import('./admin-user.js')); + describe('adminUserRoutes', () => { const userRequest = createRequester({ authedRoutes: adminUserRoutes }); @@ -123,11 +119,11 @@ describe('adminUserRoutes', () => { const username = 'MJAtLogto'; const password = 'PASSWORD'; const name = 'Michael'; - const primaryEmail = 'foo@logto.io'; + const { primaryEmail, primaryPhone } = mockUser; const response = await userRequest .post('/users') - .send({ primaryEmail, username, password, name }); + .send({ primaryEmail, primaryPhone, username, password, name }); expect(response.status).toEqual(200); expect(response.body).toEqual({ ...mockUserResponse, @@ -286,8 +282,8 @@ describe('adminUserRoutes', () => { const mockedFindRolesByRoleNames = findRolesByRoleNames as jest.Mock; mockedFindRolesByRoleNames.mockImplementationOnce( async (): Promise => [ - { name: 'worker', description: 'none' }, - { name: 'cleaner', description: 'none' }, + { id: 'role_id1', name: 'worker', description: 'none' }, + { id: 'role_id2', name: 'cleaner', description: 'none' }, ] ); await expect( diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 3d72b96c9..3d967d66b 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -4,13 +4,18 @@ import { has } from '@silverhand/essentials'; import pick from 'lodash.pick'; import { boolean, literal, object, string } from 'zod'; -import { isTrue } from '@/env-set/parameters'; -import RequestError from '@/errors/RequestError'; -import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user'; -import koaGuard from '@/middleware/koa-guard'; -import koaPagination from '@/middleware/koa-pagination'; -import { revokeInstanceByUserId } from '@/queries/oidc-model-instance'; -import { findRolesByRoleNames } from '@/queries/roles'; +import { isTrue } from '#src/env-set/parameters.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { + checkIdentifierCollision, + encryptUserPassword, + generateUserId, + insertUser, +} from '#src/lib/user.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { revokeInstanceByUserId } from '#src/queries/oidc-model-instance.js'; +import { findRolesByRoleNames } from '#src/queries/roles.js'; import { deleteUserById, deleteUserIdentity, @@ -20,11 +25,11 @@ import { hasUser, updateUserById, hasUserWithEmail, -} from '@/queries/user'; -import assertThat from '@/utils/assert-that'; + hasUserWithPhone, +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import { checkExistingSignUpIdentifiers } from './session/utils'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; export default function adminUserRoutes(router: T) { router.get( @@ -123,6 +128,7 @@ export default function adminUserRoutes(router: T) { '/users', koaGuard({ body: object({ + primaryPhone: string().regex(phoneRegEx).optional(), primaryEmail: string().regex(emailRegEx).optional(), username: string().regex(usernameRegEx).optional(), password: string().regex(passwordRegEx), @@ -130,22 +136,26 @@ export default function adminUserRoutes(router: T) { }), }), async (ctx, next) => { - const { primaryEmail, username, password, name } = ctx.guard.body; + const { primaryEmail, primaryPhone, username, password, name } = ctx.guard.body; assertThat( !username || !(await hasUser(username)), new RequestError({ - code: 'user.username_exists_register', + code: 'user.username_already_in_use', status: 422, }) ); assertThat( !primaryEmail || !(await hasUserWithEmail(primaryEmail)), new RequestError({ - code: 'user.email_exists_register', + code: 'user.email_already_in_use', status: 422, }) ); + assertThat( + !primaryPhone || !(await hasUserWithPhone(primaryPhone)), + new RequestError({ code: 'user.phone_already_in_use' }) + ); const id = await generateUserId(); @@ -154,6 +164,7 @@ export default function adminUserRoutes(router: T) { const user = await insertUser({ id, primaryEmail, + primaryPhone, username, passwordEncrypted, passwordEncryptionMethod, @@ -187,7 +198,7 @@ export default function adminUserRoutes(router: T) { } = ctx.guard; await findUserById(userId); - await checkExistingSignUpIdentifiers(body, userId); + await checkIdentifierCollision(body, userId); // Temp solution to validate the existence of input roleNames if (body.roleNames?.length) { @@ -306,7 +317,7 @@ export default function adminUserRoutes(router: T) { const { identities } = await findUserById(userId); if (!has(identities, target)) { - throw new RequestError({ code: 'user.identity_not_exists', status: 404 }); + throw new RequestError({ code: 'user.identity_not_exist', status: 404 }); } const updatedUser = await deleteUserIdentity(userId, target); diff --git a/packages/core/src/routes/application.test.ts b/packages/core/src/routes/application.test.ts index 73b0cad3c..5106240df 100644 --- a/packages/core/src/routes/application.test.ts +++ b/packages/core/src/routes/application.test.ts @@ -1,13 +1,12 @@ import type { Application, CreateApplication } from '@logto/schemas'; import { ApplicationType } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; -import { mockApplication } from '@/__mocks__'; -import { findApplicationById } from '@/queries/application'; -import { createRequester } from '@/utils/test-utils'; +import { mockApplication } from '#src/__mocks__/index.js'; -import applicationRoutes from './application'; +const { jest } = import.meta; -jest.mock('@/queries/application', () => ({ +const { findApplicationById } = mockEsm('#src/queries/application.js', () => ({ findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })), findAllApplications: jest.fn(async () => [mockApplication]), findApplicationById: jest.fn(async () => mockApplication), @@ -30,23 +29,21 @@ jest.mock('@/queries/application', () => ({ ), })); -jest.mock('@logto/shared', () => ({ +mockEsm('@logto/shared', () => ({ // eslint-disable-next-line unicorn/consistent-function-scoping buildIdGenerator: jest.fn(() => () => 'randomId'), buildApplicationSecret: jest.fn(() => 'randomId'), })); +const { createRequester } = await import('#src/utils/test-utils.js'); +const applicationRoutes = await pickDefault(import('./application.js')); + const customClientMetadata = { corsAllowedOrigins: ['http://localhost:5000', 'http://localhost:5001', 'https://silverhand.com'], idTokenTtl: 999_999, refreshTokenTtl: 100_000_000, }; -const customOidcClientMetadata = { - redirectUris: [], - postLogoutRedirectUris: [], -}; - describe('application route', () => { const applicationRequest = createRequester({ authedRoutes: applicationRoutes }); diff --git a/packages/core/src/routes/application.ts b/packages/core/src/routes/application.ts index 0cac1fcc4..b634d8c42 100644 --- a/packages/core/src/routes/application.ts +++ b/packages/core/src/routes/application.ts @@ -2,9 +2,9 @@ import { Applications } from '@logto/schemas'; import { buildApplicationSecret, buildIdGenerator } from '@logto/shared'; import { object, string } from 'zod'; -import koaGuard from '@/middleware/koa-guard'; -import koaPagination from '@/middleware/koa-pagination'; -import { buildOidcClientMetadata } from '@/oidc/utils'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { buildOidcClientMetadata } from '#src/oidc/utils.js'; import { deleteApplicationById, findApplicationById, @@ -12,9 +12,9 @@ import { insertApplication, updateApplicationById, findTotalNumberOfApplications, -} from '@/queries/application'; +} from '#src/queries/application.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; const applicationId = buildIdGenerator(21); diff --git a/packages/core/src/routes/authn.test.ts b/packages/core/src/routes/authn.test.ts index 4a856693c..7f54d02c3 100644 --- a/packages/core/src/routes/authn.test.ts +++ b/packages/core/src/routes/authn.test.ts @@ -1,11 +1,22 @@ -import RequestError from '@/errors/RequestError'; -import * as functions from '@/middleware/koa-auth'; -import { createRequester } from '@/utils/test-utils'; +import { mockEsmWithActual, pickDefault } from '@logto/shared/esm'; -import authnRoutes from './authn'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +const { verifyBearerTokenFromRequest } = await mockEsmWithActual( + '#src/middleware/koa-auth.js', + () => ({ + verifyBearerTokenFromRequest: jest.fn(), + }) +); + +const request = createRequester({ + anonymousRoutes: await pickDefault(import('#src/routes/authn.js')), +}); describe('authn route for Hasura', () => { - const request = createRequester({ anonymousRoutes: authnRoutes }); const mockUserId = 'foo'; const mockExpectedRole = 'some_role'; const mockUnauthorizedRole = 'V'; @@ -17,7 +28,7 @@ describe('authn route for Hasura', () => { describe('with successful verification', () => { beforeEach(() => { - jest.spyOn(functions, 'verifyBearerTokenFromRequest').mockResolvedValue({ + verifyBearerTokenFromRequest.mockResolvedValue({ clientId: 'ok', sub: mockUserId, roleNames: [mockExpectedRole], @@ -59,15 +70,13 @@ describe('authn route for Hasura', () => { describe('with failed verification', () => { beforeEach(() => { - jest - .spyOn(functions, 'verifyBearerTokenFromRequest') - .mockImplementation(async (_, resource) => { - if (resource) { - throw new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }); - } + verifyBearerTokenFromRequest.mockImplementation(async (_, resource) => { + if (resource) { + throw new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }); + } - return { clientId: 'not ok', sub: mockUserId }; - }); + return { clientId: 'not ok', sub: mockUserId }; + }); }); it('throws 401 if no unauthorized role presents', async () => { @@ -91,9 +100,10 @@ describe('authn route for Hasura', () => { }); it('falls back to unauthorized role if JWT is invalid', async () => { - jest - .spyOn(functions, 'verifyBearerTokenFromRequest') - .mockRejectedValue(new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); + verifyBearerTokenFromRequest.mockRejectedValue( + new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }) + ); + const response = await request .get('/authn/hasura') .query({ resource: 'https://api.logto.io', unauthorizedRole: mockUnauthorizedRole }); diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index aa523a84a..03f64d6df 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { verifyBearerTokenFromRequest } from '@/middleware/koa-auth'; -import koaGuard from '@/middleware/koa-guard'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from './types'; +import type { AnonymousRouter } from './types.js'; /** * Authn stands for authentication. diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector.test.ts index 019e9b64f..e4398ac9b 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector.test.ts @@ -1,25 +1,60 @@ +/* eslint-disable max-lines */ import type { EmailConnector, SmsConnector } from '@logto/connector-kit'; -import { MessageTypes } from '@logto/connector-kit'; +import { ConnectorPlatform, MessageTypes } from '@logto/connector-kit'; import { ConnectorType } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import { any } from 'zod'; -import { mockMetadata, mockConnector, mockLogtoConnectorList } from '@/__mocks__'; -import { defaultConnectorMethods } from '@/connectors/consts'; -import type { LogtoConnector } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import assertThat from '@/utils/assert-that'; -import { createRequester } from '@/utils/test-utils'; +import { + mockMetadata, + mockMetadata0, + mockMetadata1, + mockMetadata2, + mockMetadata3, + mockConnector, + mockConnectorFactory, + mockLogtoConnectorList, + mockLogtoConnector, +} from '#src/__mocks__/index.js'; +import { defaultConnectorMethods } from '#src/connectors/consts.js'; +import type { LogtoConnector } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import connectorRoutes from './connector'; +const { jest } = import.meta; -const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction< - () => Promise ->; +mockEsm('#src/lib/connector.js', () => ({ + checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(), +})); -jest.mock('@/connectors', () => ({ - getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(), +const { removeUnavailableSocialConnectorTargets } = mockEsm( + '#src/lib/sign-in-experience/index.js', + () => ({ + removeUnavailableSocialConnectorTargets: jest.fn(), + }) +); + +const { + findConnectorById, + countConnectorByConnectorId, + deleteConnectorById, + deleteConnectorByIds, +} = await mockEsmWithActual('#src/queries/connector.js', () => ({ + findConnectorById: jest.fn(), + countConnectorByConnectorId: jest.fn(), + deleteConnectorById: jest.fn(), + deleteConnectorByIds: jest.fn(), + insertConnector: jest.fn(async (body: unknown) => body), +})); + +// eslint-disable-next-line @typescript-eslint/ban-types +const getLogtoConnectors = jest.fn, []>(); +const { loadConnectorFactories } = mockEsm('#src/connectors/index.js', () => ({ + loadConnectorFactories: jest.fn(), + getLogtoConnectors, getLogtoConnectorById: async (connectorId: string) => { - const connectors = await getLogtoConnectorsPlaceHolder(); + const connectors = await getLogtoConnectors(); const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId); assertThat( connector, @@ -33,6 +68,7 @@ jest.mock('@/connectors', () => ({ return connector; }, })); +const connectorRoutes = await pickDefault(import('./connector.js')); describe('connector route', () => { const connectorRequest = createRequester({ authedRoutes: connectorRoutes }); @@ -42,14 +78,14 @@ describe('connector route', () => { jest.clearAllMocks(); }); - it('throws if more than one email connector is enabled', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList); + it('throws if more than one email connector exists', async () => { + getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList); const response = await connectorRequest.get('/connectors').send({}); expect(response).toHaveProperty('statusCode', 400); }); - it('throws if more than one SMS connector is enabled', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce( + it('throws if more than one SMS connector exists', async () => { + getLogtoConnectors.mockResolvedValueOnce( mockLogtoConnectorList.filter((connector) => connector.type !== ConnectorType.Email) ); const response = await connectorRequest.get('/connectors').send({}); @@ -57,7 +93,7 @@ describe('connector route', () => { }); it('shows all connectors', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce( + getLogtoConnectors.mockResolvedValueOnce( mockLogtoConnectorList.filter((connector) => connector.type === ConnectorType.Social) ); const response = await connectorRequest.get('/connectors').send({}); @@ -65,30 +101,256 @@ describe('connector route', () => { }); }); + describe('GET /connector-factories', () => { + it('show all connector factories', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { ...mockConnectorFactory, metadata: mockMetadata0, type: ConnectorType.Sms }, + { ...mockConnectorFactory, metadata: mockMetadata1, type: ConnectorType.Social }, + { ...mockConnectorFactory, metadata: mockMetadata2, type: ConnectorType.Email }, + { ...mockConnectorFactory, metadata: mockMetadata3, type: ConnectorType.Social }, + ]); + const response = await connectorRequest.get('/connector-factories').send({}); + expect(response.body).toMatchObject([ + { ...mockMetadata0, type: ConnectorType.Sms }, + { ...mockMetadata1, type: ConnectorType.Social }, + { ...mockMetadata2, type: ConnectorType.Email }, + { ...mockMetadata3, type: ConnectorType.Social }, + ]); + expect(response).toHaveProperty('statusCode', 200); + }); + }); + describe('GET /connectors/:id', () => { afterEach(() => { jest.clearAllMocks(); }); it('throws when connector can not be found by given connectorId (locally)', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList.slice(2)); + getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList.slice(2)); const response = await connectorRequest.get('/connectors/findConnector').send({}); expect(response).toHaveProperty('statusCode', 404); }); it('throws when connector can not be found by given connectorId (remotely)', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]); + getLogtoConnectors.mockResolvedValueOnce([]); const response = await connectorRequest.get('/connectors/id0').send({}); expect(response).toHaveProperty('statusCode', 404); }); it('shows found connector information', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList); + getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList); const response = await connectorRequest.get('/connectors/id0').send({}); expect(response).toHaveProperty('statusCode', 200); }); }); + describe('POST /connectors', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should post a new connector record', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { ...mockConnectorFactory.metadata, id: 'connectorId' }, + }, + ]); + countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + getLogtoConnectors.mockResolvedValueOnce([ + { + dbEntry: { ...mockConnector, connectorId: 'id0' }, + metadata: { ...mockMetadata, id: 'id0' }, + type: ConnectorType.Sms, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'connectorId', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response.body).toMatchObject( + expect.objectContaining({ + connectorId: 'connectorId', + config: { + cliend_id: 'client_id', + client_secret: 'client_secret', + }, + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('throws when connector factory not found', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { ...mockConnectorFactory.metadata, id: 'connectorId' }, + }, + ]); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); + + it('should post a new record when add more than 1 instance with connector factory', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { + ...mockMetadata, + id: 'id0', + isStandard: true, + platform: ConnectorPlatform.Universal, + }, + }, + ]); + countConnectorByConnectorId.mockResolvedValueOnce({ count: 1 }); + getLogtoConnectors.mockResolvedValueOnce([ + { + dbEntry: { ...mockConnector, connectorId: 'id0' }, + metadata: { ...mockMetadata, id: 'id0', platform: ConnectorPlatform.Universal }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + metadata: { target: 'new_target' }, + }); + expect(response.body).toMatchObject( + expect.objectContaining({ + connectorId: 'id0', + config: { + cliend_id: 'client_id', + client_secret: 'client_secret', + }, + metadata: { target: 'new_target' }, + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('throws when add more than 1 instance with non-connector factory', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { ...mockConnectorFactory.metadata, id: 'id0' }, + }, + ]); + countConnectorByConnectorId.mockResolvedValueOnce({ count: 1 }); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); + + it('should add a new record and delete old records with same connector type when add passwordless connectors', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + type: ConnectorType.Sms, + metadata: { ...mockConnectorFactory.metadata, id: 'id1' }, + }, + ]); + getLogtoConnectors.mockResolvedValueOnce([ + { + dbEntry: { ...mockConnector, connectorId: 'id0' }, + metadata: { ...mockMetadata, id: 'id0' }, + type: ConnectorType.Sms, + ...mockLogtoConnector, + }, + ]); + countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id1', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response).toHaveProperty('statusCode', 200); + expect(response.body).toMatchObject( + expect.objectContaining({ + connectorId: 'id1', + config: { + cliend_id: 'client_id', + client_secret: 'client_secret', + }, + }) + ); + expect(deleteConnectorByIds).toHaveBeenCalledWith(['id']); + }); + + it('throws when add more than 1 social connector instance with same target and platform (add from standard connector)', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { + ...mockConnectorFactory.metadata, + id: 'id0', + platform: ConnectorPlatform.Universal, + isStandard: true, + }, + }, + ]); + countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + getLogtoConnectors.mockResolvedValueOnce([ + { + dbEntry: { ...mockConnector, connectorId: 'id0', metadata: { target: 'target' } }, + metadata: { + ...mockMetadata, + id: 'id0', + target: 'target', + platform: ConnectorPlatform.Universal, + }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + metadata: { target: 'target' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); + + it('throws when add more than 1 social connector instance with same target and platform (add social connector)', async () => { + loadConnectorFactories.mockResolvedValueOnce([ + { + ...mockConnectorFactory, + metadata: { + ...mockConnectorFactory.metadata, + id: 'id0', + platform: ConnectorPlatform.Universal, + target: 'target', + isStandard: true, + }, + }, + ]); + countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + getLogtoConnectors.mockResolvedValueOnce([ + { + dbEntry: { ...mockConnector, connectorId: 'id0', metadata: { target: 'target' } }, + metadata: { + ...mockMetadata, + id: 'id0', + target: 'target', + platform: ConnectorPlatform.Universal, + }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + metadata: { target: 'target' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); + }); + describe('POST /connectors/:id/test', () => { afterEach(() => { jest.clearAllMocks(); @@ -107,7 +369,7 @@ describe('connector route', () => { ...defaultConnectorMethods, sendMessage, }; - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([mockedSmsConnector]); + getLogtoConnectors.mockResolvedValueOnce([mockedSmsConnector]); const response = await connectorRequest .post('/connectors/id/test') .send({ phone: '12345678901', config: { test: 123 } }); @@ -135,7 +397,7 @@ describe('connector route', () => { ...defaultConnectorMethods, sendMessage, }; - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([mockedEmailConnector]); + getLogtoConnectors.mockResolvedValueOnce([mockedEmailConnector]); const response = await connectorRequest .post('/connectors/id/test') .send({ email: 'test@email.com', config: { test: 123 } }); @@ -159,7 +421,7 @@ describe('connector route', () => { }); it('should throw when sms connector is not found', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]); + getLogtoConnectors.mockResolvedValueOnce([]); const response = await connectorRequest .post('/connectors/id/test') .send({ phone: '12345678901' }); @@ -167,11 +429,55 @@ describe('connector route', () => { }); it('should throw when email connector is not found', async () => { - getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]); + getLogtoConnectors.mockResolvedValueOnce([]); const response = await connectorRequest .post('/connectors/id/test') .send({ email: 'test@email.com' }); expect(response).toHaveProperty('statusCode', 400); }); }); + + describe('DELETE /connectors/:id', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('delete connector instance and remove unavailable social connector targets', async () => { + findConnectorById.mockResolvedValueOnce(mockConnector); + loadConnectorFactories.mockResolvedValueOnce([mockConnectorFactory]); + await connectorRequest.delete('/connectors/id').send({}); + expect(deleteConnectorById).toHaveBeenCalledTimes(1); + expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(1); + }); + + it('delete connector instance (connector factory is not social type)', async () => { + findConnectorById.mockResolvedValueOnce(mockConnector); + loadConnectorFactories.mockResolvedValueOnce([ + { ...mockConnectorFactory, type: ConnectorType.Sms }, + ]); + await connectorRequest.delete('/connectors/id').send({}); + expect(deleteConnectorById).toHaveBeenCalledTimes(1); + expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0); + }); + + it('delete connector instance (connector factory is not found)', async () => { + findConnectorById.mockResolvedValueOnce(mockConnector); + loadConnectorFactories.mockResolvedValueOnce([]); + await connectorRequest.delete('/connectors/id').send({}); + expect(deleteConnectorById).toHaveBeenCalledTimes(1); + expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0); + }); + + it('throws when connector not exists with `id`', async () => { + // eslint-disable-next-line unicorn/no-useless-undefined + findConnectorById.mockResolvedValueOnce(undefined); + const response = await connectorRequest.delete('/connectors/id').send({}); + expect(response).toHaveProperty('statusCode', 500); + }); + }); }); +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index c03e25fbb..8673c615d 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -1,18 +1,32 @@ import { MessageTypes } from '@logto/connector-kit'; import { emailRegEx, phoneRegEx } from '@logto/core-kit'; -import type { ConnectorResponse } from '@logto/schemas'; +import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas'; import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas'; +import { buildIdGenerator } from '@logto/shared'; +import cleanDeep from 'clean-deep'; import { object, string } from 'zod'; -import { getLogtoConnectorById, getLogtoConnectors } from '@/connectors'; -import type { LogtoConnector } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import { removeUnavailableSocialConnectorTargets } from '@/lib/sign-in-experience'; -import koaGuard from '@/middleware/koa-guard'; -import { updateConnector } from '@/queries/connector'; -import assertThat from '@/utils/assert-that'; +import { + getLogtoConnectorById, + getLogtoConnectors, + loadConnectorFactories, +} from '#src/connectors/index.js'; +import type { LogtoConnector } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/lib/connector.js'; +import { removeUnavailableSocialConnectorTargets } from '#src/lib/sign-in-experience/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { + findConnectorById, + countConnectorByConnectorId, + deleteConnectorById, + deleteConnectorByIds, + insertConnector, + updateConnector, +} from '#src/queries/connector.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; const transpileLogtoConnector = ({ dbEntry, @@ -24,6 +38,8 @@ const transpileLogtoConnector = ({ ...dbEntry, }); +const generateConnectorId = buildIdGenerator(12); + export default function connectorRoutes(router: T) { router.get( '/connectors', @@ -36,16 +52,14 @@ export default function connectorRoutes(router: T) { const { target: filterTarget } = ctx.query; const connectors = await getLogtoConnectors(); + checkSocialConnectorTargetAndPlatformUniqueness(connectors); + assertThat( - connectors.filter( - (connector) => connector.dbEntry.enabled && connector.type === ConnectorType.Email - ).length <= 1, + connectors.filter((connector) => connector.type === ConnectorType.Email).length <= 1, 'connector.more_than_one_email' ); assertThat( - connectors.filter( - (connector) => connector.dbEntry.enabled && connector.type === ConnectorType.Sms - ).length <= 1, + connectors.filter((connector) => connector.type === ConnectorType.Sms).length <= 1, 'connector.more_than_one_sms' ); @@ -59,6 +73,19 @@ export default function connectorRoutes(router: T) { } ); + router.get('/connector-factories', async (ctx, next) => { + const connectorFactories = await loadConnectorFactories(); + const formatedFactories: ConnectorFactoryResponse[] = connectorFactories.map( + ({ metadata, type }) => ({ + type, + ...metadata, + }) + ); + ctx.body = formatedFactories; + + return next(); + }); + router.get( '/connectors/:id', koaGuard({ params: object({ id: string().min(1) }) }), @@ -73,56 +100,93 @@ export default function connectorRoutes(router: T) { } ); - router.patch( - '/connectors/:id/enabled', + router.post( + '/connectors', koaGuard({ - params: object({ id: string().min(1) }), - body: Connectors.createGuard.pick({ enabled: true }), + body: Connectors.createGuard.pick({ + config: true, + connectorId: true, + metadata: true, + syncProfile: true, + }), }), + // eslint-disable-next-line complexity async (ctx, next) => { const { - params: { id }, - body: { enabled }, + body: { connectorId, metadata, config, syncProfile }, } = ctx.guard; - const { - type, - dbEntry: { config }, - metadata, - validateConfig, - } = await getLogtoConnectorById(id); + const connectorFactories = await loadConnectorFactories(); + const connectorFactory = connectorFactories.find( + ({ metadata: { id } }) => id === connectorId + ); - if (enabled) { - validateConfig(config); + if (!connectorFactory) { + throw new RequestError({ + code: 'connector.not_found_with_connector_id', + status: 422, + }); } - // Only allow one enabled connector for SMS and Email. - // disable other connectors before enable this one. - if (enabled && (type === ConnectorType.Sms || type === ConnectorType.Email)) { + assertThat( + connectorFactory.metadata.isStandard !== true || metadata?.target, + 'connector.can_not_modify_target' + ); + assertThat( + connectorFactory.metadata.isStandard === true || metadata === undefined, + 'connector.cannot_change_metadata_for_non_standard_connector' + ); + + const { count } = await countConnectorByConnectorId(connectorId); + assertThat( + count === 0 || connectorFactory.metadata.isStandard === true, + new RequestError({ + code: 'connector.multiple_instances_not_supported', + status: 422, + }) + ); + + if (connectorFactory.type === ConnectorType.Social) { const connectors = await getLogtoConnectors(); - await Promise.all( - connectors - .filter( - ({ dbEntry: { enabled }, type: currentType }) => type === currentType && enabled - ) - .map(async ({ dbEntry: { id } }) => - updateConnector({ set: { enabled: false }, where: { id }, jsonbMode: 'merge' }) - ) + assertThat( + !connectors + .filter(({ type }) => type === ConnectorType.Social) + .some( + ({ metadata: { target, platform } }) => + target === cleanDeep(metadata)?.target && + platform === connectorFactory.metadata.platform + ), + new RequestError({ code: 'connector.multiple_target_with_same_platform', status: 422 }) ); } - const connector = await updateConnector({ - set: { enabled }, - where: { id }, - jsonbMode: 'merge', + const insertConnectorId = generateConnectorId(); + ctx.body = await insertConnector({ + id: insertConnectorId, + connectorId, + ...cleanDeep({ syncProfile, config, metadata }), }); - // Delete the social connector in the sign-in experience if it is disabled. - if (!enabled && type === ConnectorType.Social) { - await removeUnavailableSocialConnectorTargets(); - } + /** + * We can have only one working email/sms connector: + * once we insert a new one, old connectors with same type should be deleted. + */ + if ( + connectorFactory.type === ConnectorType.Sms || + connectorFactory.type === ConnectorType.Email + ) { + const logtoConnectors = await getLogtoConnectors(); + const conflictingConnectorIds = logtoConnectors + .filter( + ({ dbEntry: { id }, type }) => + type === connectorFactory.type && id !== insertConnectorId + ) + .map(({ dbEntry: { id } }) => id); - ctx.body = { ...connector, metadata, type }; + if (conflictingConnectorIds.length > 0) { + await deleteConnectorByIds(conflictingConnectorIds); + } + } return next(); } @@ -132,22 +196,46 @@ export default function connectorRoutes(router: T) { '/connectors/:id', koaGuard({ params: object({ id: string().min(1) }), - body: Connectors.createGuard.omit({ id: true, enabled: true, createdAt: true }).partial(), + body: Connectors.createGuard + .pick({ config: true, metadata: true, syncProfile: true }) + .partial(), }), async (ctx, next) => { const { params: { id }, - body, + body: { config, metadata, syncProfile }, } = ctx.guard; - const { metadata, type, validateConfig } = await getLogtoConnectorById(id); + const { type, validateConfig, metadata: originalMetadata } = await getLogtoConnectorById(id); - if (body.config) { - validateConfig(body.config); + assertThat( + originalMetadata.isStandard !== true || metadata?.target === originalMetadata.target, + 'connector.can_not_modify_target' + ); + + assertThat( + originalMetadata.isStandard === true || metadata === undefined, + 'connector.cannot_change_metadata_for_non_standard_connector' + ); + + if (syncProfile) { + assertThat( + type === ConnectorType.Social, + new RequestError({ code: 'connector.invalid_type_for_syncing_profile', status: 422 }) + ); } - const connector = await updateConnector({ set: body, where: { id }, jsonbMode: 'replace' }); - ctx.body = { ...connector, metadata, type }; + if (config) { + validateConfig(config); + } + + await updateConnector({ + set: cleanDeep({ config, metadata, syncProfile }), + where: { id }, + jsonbMode: 'replace', + }); + const connector = await getLogtoConnectorById(id); + ctx.body = transpileLogtoConnector(connector); return next(); } @@ -170,11 +258,10 @@ export default function connectorRoutes(router: T) { } = ctx.guard; const { phone, email, config } = body; - const logtoConnectors = await getLogtoConnectors(); const subject = phone ?? email; assertThat(subject, new RequestError({ code: 'guard.invalid_input' })); - const connector = logtoConnectors.find(({ metadata: { id: currentId } }) => currentId === id); + const connector = await getLogtoConnectorById(id); const expectType = phone ? ConnectorType.Sms : ConnectorType.Email; assertThat( @@ -204,4 +291,30 @@ export default function connectorRoutes(router: T) { return next(); } ); + + router.delete( + '/connectors/:id', + koaGuard({ params: object({ id: string().min(1) }) }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + const { connectorId } = await findConnectorById(id); + const connectorFactories = await loadConnectorFactories(); + const connectorFactory = connectorFactories.find( + ({ metadata }) => metadata.id === connectorId + ); + + await deleteConnectorById(id); + + if (connectorFactory?.type === ConnectorType.Social) { + await removeUnavailableSocialConnectorTargets(); + } + + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/core/src/routes/connector.update.test.ts b/packages/core/src/routes/connector.update.test.ts index 3e9eb3094..f71e1bae0 100644 --- a/packages/core/src/routes/connector.update.test.ts +++ b/packages/core/src/routes/connector.update.test.ts @@ -1,25 +1,23 @@ import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { ConnectorType } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import { mockMetadata, mockConnector, mockLogtoConnectorList, mockLogtoConnector, -} from '@/__mocks__'; -import type { LogtoConnector } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import { updateConnector } from '@/queries/connector'; -import assertThat from '@/utils/assert-that'; -import { createRequester } from '@/utils/test-utils'; +} from '#src/__mocks__/index.js'; +import type { LogtoConnector } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import connectorRoutes from './connector'; +const { jest } = import.meta; -const getLogtoConnectorsPlaceholder = jest.fn() as jest.MockedFunction< - () => Promise ->; -const getLogtoConnectorByIdPlaceholder = jest.fn(async (connectorId: string) => { - const connectors = await getLogtoConnectorsPlaceholder(); +const getLogtoConnectors = jest.fn() as jest.MockedFunction<() => Promise>; +const getLogtoConnectorById = jest.fn(async (connectorId: string) => { + const connectors = await getLogtoConnectors(); const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId); assertThat( @@ -36,226 +34,47 @@ const getLogtoConnectorByIdPlaceholder = jest.fn(async (connectorId: string) => sendMessage: sendMessagePlaceHolder, }; }) as jest.MockedFunction<(connectorId: string) => Promise>; + const sendMessagePlaceHolder = jest.fn(); -jest.mock('@/queries/connector', () => ({ +const { updateConnector } = await mockEsmWithActual('#src/queries/connector.js', () => ({ updateConnector: jest.fn(), })); -jest.mock('@/connectors', () => ({ - getLogtoConnectors: async () => getLogtoConnectorsPlaceholder(), - getLogtoConnectorById: async (connectorId: string) => - getLogtoConnectorByIdPlaceholder(connectorId), + +await mockEsmWithActual('#src/connectors.js', () => ({ + getLogtoConnectors, + getLogtoConnectorById, })); -jest.mock('@/lib/sign-in-experience', () => ({ + +mockEsm('#src/lib/sign-in-experience.js', () => ({ // eslint-disable-next-line @typescript-eslint/no-empty-function removeUnavailableSocialConnectorTargets: async () => {}, })); +const connectorRoutes = await pickDefault(import('./connector.js')); + describe('connector PATCH routes', () => { const connectorRequest = createRequester({ authedRoutes: connectorRoutes }); - describe('PATCH /connectors/:id/enabled', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('throws if connector can not be found (locally)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(1)); - const response = await connectorRequest - .patch('/connectors/findConnector/enabled') - .send({ enabled: true }); - expect(response).toHaveProperty('statusCode', 404); - }); - - it('throws if connector can not be found (remotely)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]); - const response = await connectorRequest - .patch('/connectors/id0/enabled') - .send({ enabled: true }); - expect(response).toHaveProperty('statusCode', 404); - }); - - it('enables one of the social connectors (with valid config)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ - { - dbEntry: mockConnector, - metadata: mockMetadata, - type: ConnectorType.Social, - ...mockLogtoConnector, - }, - ]); - const response = await connectorRequest - .patch('/connectors/id/enabled') - .send({ enabled: true }); - expect(updateConnector).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'id' }, - set: { enabled: true }, - jsonbMode: 'merge', - }) - ); - expect(response.body).toMatchObject({ - metadata: mockMetadata, - type: ConnectorType.Social, - }); - expect(response).toHaveProperty('statusCode', 200); - }); - - it('enables one of the social connectors (with invalid config)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ - { - dbEntry: mockConnector, - metadata: mockMetadata, - type: ConnectorType.Social, - ...mockLogtoConnector, - validateConfig: () => { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig); - }, - }, - ]); - const response = await connectorRequest - .patch('/connectors/id/enabled') - .send({ enabled: true }); - expect(response).toHaveProperty('statusCode', 500); - }); - - it('disables one of the social connectors', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ - { - dbEntry: mockConnector, - metadata: mockMetadata, - type: ConnectorType.Social, - ...mockLogtoConnector, - }, - ]); - const response = await connectorRequest - .patch('/connectors/id/enabled') - .send({ enabled: false }); - expect(updateConnector).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'id' }, - set: { enabled: false }, - jsonbMode: 'merge', - }) - ); - expect(response.body).toMatchObject({ - metadata: mockMetadata, - }); - expect(response).toHaveProperty('statusCode', 200); - }); - - it('enables one of the email/sms connectors (with valid config)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList); - const mockedMetadata = { - ...mockMetadata, - id: 'id1', - }; - const mockedConnector = { - ...mockConnector, - id: 'id1', - }; - getLogtoConnectorByIdPlaceholder.mockResolvedValueOnce({ - dbEntry: mockedConnector, - metadata: mockedMetadata, - type: ConnectorType.Sms, - ...mockLogtoConnector, - }); - const response = await connectorRequest - .patch('/connectors/id1/enabled') - .send({ enabled: true }); - expect(response).toHaveProperty('statusCode', 200); - expect(updateConnector).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - where: { id: 'id1' }, - set: { enabled: false }, - jsonbMode: 'merge', - }) - ); - expect(updateConnector).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - where: { id: 'id5' }, - set: { enabled: false }, - jsonbMode: 'merge', - }) - ); - expect(updateConnector).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - where: { id: 'id1' }, - set: { enabled: true }, - jsonbMode: 'merge', - }) - ); - expect(response.body).toMatchObject({ - metadata: mockedMetadata, - }); - }); - - it('enables one of the email/sms connectors (with invalid config)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ - { - dbEntry: mockConnector, - metadata: mockMetadata, - type: ConnectorType.Sms, - ...mockLogtoConnector, - validateConfig: () => { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig); - }, - }, - ]); - const response = await connectorRequest - .patch('/connectors/id/enabled') - .send({ enabled: true }); - expect(response).toHaveProperty('statusCode', 500); - }); - - it('disables one of the email/sms connectors', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ - { - dbEntry: mockConnector, - metadata: mockMetadata, - type: ConnectorType.Sms, - ...mockLogtoConnector, - }, - ]); - const response = await connectorRequest - .patch('/connectors/id/enabled') - .send({ enabled: false }); - expect(updateConnector).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'id' }, - set: { enabled: false }, - jsonbMode: 'merge', - }) - ); - expect(response.body).toMatchObject({ - metadata: mockMetadata, - }); - expect(response).toHaveProperty('statusCode', 200); - }); - }); - describe('PATCH /connectors/:id', () => { afterEach(() => { jest.clearAllMocks(); }); it('throws when connector can not be found by given connectorId (locally)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(0, 1)); + getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList.slice(0, 1)); const response = await connectorRequest.patch('/connectors/findConnector').send({}); expect(response).toHaveProperty('statusCode', 404); }); it('throws when connector can not be found by given connectorId (remotely)', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]); + getLogtoConnectors.mockResolvedValueOnce([]); const response = await connectorRequest.patch('/connectors/id0').send({}); expect(response).toHaveProperty('statusCode', 404); }); it('config validation fails', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ + getLogtoConnectors.mockResolvedValueOnce([ { dbEntry: mockConnector, metadata: mockMetadata, @@ -272,8 +91,101 @@ describe('connector PATCH routes', () => { expect(response).toHaveProperty('statusCode', 500); }); + it('throws when trying to update target', async () => { + getLogtoConnectors.mockResolvedValue([ + { + dbEntry: mockConnector, + metadata: { ...mockMetadata, isStandard: true }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.patch('/connectors/id').send({ + metadata: { + target: 'target', + }, + }); + expect(response).toHaveProperty('statusCode', 400); + }); + it('successfully updates connector configs', async () => { - getLogtoConnectorsPlaceholder.mockResolvedValueOnce([ + getLogtoConnectors.mockResolvedValue([ + { + dbEntry: mockConnector, + metadata: { ...mockMetadata, isStandard: true }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + updateConnector.mockResolvedValueOnce({ + ...mockConnector, + metadata: { + target: 'connector', + name: { en: 'connector_name', fr: 'connector_name' }, + logo: 'new_logo.png', + }, + }); + const response = await connectorRequest.patch('/connectors/id').send({ + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + metadata: { + name: { en: 'connector_name', fr: 'connector_name' }, + logo: 'new_logo.png', + logoDark: null, + target: 'connector', + }, + }); + expect(updateConnector).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'id' }, + set: { + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + metadata: { + name: { en: 'connector_name', fr: 'connector_name' }, + logo: 'new_logo.png', + target: 'connector', + }, + }, + jsonbMode: 'replace', + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('successfully clear connector config metadata', async () => { + getLogtoConnectors.mockResolvedValueOnce([ + { + dbEntry: mockConnector, + metadata: { ...mockMetadata, isStandard: true }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + updateConnector.mockResolvedValueOnce({ + ...mockConnector, + metadata: { + target: 'connector', + name: { en: '' }, + logo: '', + logoDark: '', + }, + }); + const response = await connectorRequest.patch('/connectors/id').send({ + metadata: { target: 'connector', name: { en: '' }, logo: '', logoDark: '' }, + }); + expect(updateConnector).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'id' }, + set: { + metadata: { target: 'connector' }, + }, + jsonbMode: 'replace', + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('throws when set syncProfile to `true` and with non-social connector', async () => { + getLogtoConnectors.mockResolvedValueOnce([ { dbEntry: mockConnector, metadata: mockMetadata, @@ -281,19 +193,48 @@ describe('connector PATCH routes', () => { ...mockLogtoConnector, }, ]); - const response = await connectorRequest - .patch('/connectors/id') - .send({ config: { cliend_id: 'client_id', client_secret: 'client_secret' } }); + const response = await connectorRequest.patch('/connectors/id').send({ syncProfile: true }); + expect(response).toHaveProperty('statusCode', 422); + expect(updateConnector).toHaveBeenCalledTimes(0); + }); + + it('successfully set syncProfile to `true` and with social connector', async () => { + getLogtoConnectors.mockResolvedValue([ + { + dbEntry: { ...mockConnector, syncProfile: false }, + metadata: mockMetadata, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.patch('/connectors/id').send({ syncProfile: true }); expect(updateConnector).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'id' }, - set: { config: { cliend_id: 'client_id', client_secret: 'client_secret' } }, + set: { syncProfile: true }, + jsonbMode: 'replace', + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('successfully set syncProfile to `false`', async () => { + getLogtoConnectors.mockResolvedValue([ + { + dbEntry: { ...mockConnector, syncProfile: false }, + metadata: mockMetadata, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.patch('/connectors/id').send({ syncProfile: false }); + expect(updateConnector).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'id' }, + set: { syncProfile: false }, jsonbMode: 'replace', }) ); - expect(response.body).toMatchObject({ - metadata: mockMetadata, - }); expect(response).toHaveProperty('statusCode', 200); }); }); diff --git a/packages/core/src/routes/consts.ts b/packages/core/src/routes/consts.ts index 5daf0f88d..d1099ad24 100644 --- a/packages/core/src/routes/consts.ts +++ b/packages/core/src/routes/consts.ts @@ -6,3 +6,6 @@ export const routes = Object.freeze({ consent: signIn + '/consent', }, }); + +export const verificationTimeout = 10 * 60; // 10 mins. +export const continueSignInTimeout = 10 * 60; // 10 mins. diff --git a/packages/core/src/routes/custom-phrase.test.ts b/packages/core/src/routes/custom-phrase.test.ts index 007df147b..be0c35550 100644 --- a/packages/core/src/routes/custom-phrase.test.ts +++ b/packages/core/src/routes/custom-phrase.test.ts @@ -1,11 +1,13 @@ -import en from '@logto/phrases-ui/lib/locales/en'; -import type { CustomPhrase, SignInExperience, Translation } from '@logto/schemas'; +import en from '@logto/phrases-ui/lib/locales/en.js'; +import type { CustomPhrase, SignInExperience } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; -import { mockSignInExperience } from '@/__mocks__'; -import { mockZhCnCustomPhrase, trTrTag, zhCnTag } from '@/__mocks__/custom-phrase'; -import RequestError from '@/errors/RequestError'; -import customPhraseRoutes from '@/routes/custom-phrase'; -import { createRequester } from '@/utils/test-utils'; +import { mockZhCnCustomPhrase, trTrTag, zhCnTag } from '#src/__mocks__/custom-phrase.js'; +import { mockSignInExperience } from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; const mockLanguageTag = zhCnTag; const mockPhrase = mockZhCnCustomPhrase; @@ -13,61 +15,52 @@ const mockCustomPhrases: Record = { [mockLanguageTag]: mockPhrase, }; -const deleteCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => { - if (!mockCustomPhrases[languageTag]) { - throw new RequestError({ code: 'entity.not_found', status: 404 }); - } -}); +const { + deleteCustomPhraseByLanguageTag, + findAllCustomPhrases, + findCustomPhraseByLanguageTag, + upsertCustomPhrase, +} = mockEsm('#src/queries/custom-phrase.js', () => ({ + deleteCustomPhraseByLanguageTag: jest.fn(async (languageTag: string) => { + if (!mockCustomPhrases[languageTag]) { + throw new RequestError({ code: 'entity.not_found', status: 404 }); + } + }), + findAllCustomPhrases: jest.fn(async (): Promise => []), + findCustomPhraseByLanguageTag: jest.fn(async (languageTag: string) => { + const mockCustomPhrase = mockCustomPhrases[languageTag]; -const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => { - const mockCustomPhrase = mockCustomPhrases[languageTag]; + if (!mockCustomPhrase) { + throw new RequestError({ code: 'entity.not_found', status: 404 }); + } - if (!mockCustomPhrase) { - throw new RequestError({ code: 'entity.not_found', status: 404 }); - } - - return mockCustomPhrase; -}); - -const findAllCustomPhrases = jest.fn(async (): Promise => []); - -const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => mockPhrase); - -jest.mock('@/queries/custom-phrase', () => ({ - deleteCustomPhraseByLanguageTag: async (tag: string) => deleteCustomPhraseByLanguageTag(tag), - findAllCustomPhrases: async () => findAllCustomPhrases(), - findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag), - upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase), + return mockCustomPhrase; + }), + upsertCustomPhrase: jest.fn(async () => mockPhrase), })); -const isStrictlyPartial = jest.fn( - (fullTranslation: Translation, partialTranslation: Partial) => true -); - -jest.mock('@/utils/translation', () => ({ - isStrictlyPartial: (fullTranslation: Translation, partialTranslation: Translation) => - isStrictlyPartial(fullTranslation, partialTranslation), +const { isStrictlyPartial } = mockEsm('#src/utils/translation.js', () => ({ + isStrictlyPartial: jest.fn(() => true), })); const mockFallbackLanguage = trTrTag; -const findDefaultSignInExperience = jest.fn( - async (): Promise => ({ - ...mockSignInExperience, - languageInfo: { - autoDetect: true, - fallbackLanguage: mockFallbackLanguage, - }, - }) -); - -jest.mock('@/queries/sign-in-experience', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), +mockEsm('#src/queries/sign-in-experience.js', () => ({ + findDefaultSignInExperience: jest.fn( + async (): Promise => ({ + ...mockSignInExperience, + languageInfo: { + autoDetect: true, + fallbackLanguage: mockFallbackLanguage, + }, + }) + ), })); -describe('customPhraseRoutes', () => { - const customPhraseRequest = createRequester({ authedRoutes: customPhraseRoutes }); +const customPhraseRoutes = await pickDefault(import('./custom-phrase.js')); +const customPhraseRequest = createRequester({ authedRoutes: customPhraseRoutes }); +describe('customPhraseRoutes', () => { afterEach(() => { jest.clearAllMocks(); }); diff --git a/packages/core/src/routes/custom-phrase.ts b/packages/core/src/routes/custom-phrase.ts index 95c344f94..f446b2f6e 100644 --- a/packages/core/src/routes/custom-phrase.ts +++ b/packages/core/src/routes/custom-phrase.ts @@ -5,19 +5,19 @@ import { CustomPhrases, translationGuard } from '@logto/schemas'; import cleanDeep from 'clean-deep'; import { object } from 'zod'; -import RequestError from '@/errors/RequestError'; -import koaGuard from '@/middleware/koa-guard'; +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import { deleteCustomPhraseByLanguageTag, findAllCustomPhrases, findCustomPhraseByLanguageTag, upsertCustomPhrase, -} from '@/queries/custom-phrase'; -import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; -import assertThat from '@/utils/assert-that'; -import { isStrictlyPartial } from '@/utils/translation'; +} from '#src/queries/custom-phrase.js'; +import { findDefaultSignInExperience } from '#src/queries/sign-in-experience.js'; +import assertThat from '#src/utils/assert-that.js'; +import { isStrictlyPartial } from '#src/utils/translation.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; const cleanDeepTranslation = (translation: Translation) => // Since `Translation` type actually equals `Partial`, force to cast it back to `Translation`. diff --git a/packages/core/src/routes/dashboard.test.ts b/packages/core/src/routes/dashboard.test.ts index 7e960ab5b..e8766c7de 100644 --- a/packages/core/src/routes/dashboard.test.ts +++ b/packages/core/src/routes/dashboard.test.ts @@ -1,25 +1,20 @@ // The FP version works better for `format()` /* eslint-disable import/no-duplicates */ +import { mockEsm, pickDefault } from '@logto/shared/esm'; import { endOfDay, subDays } from 'date-fns'; import { format } from 'date-fns/fp'; + +import { createRequester } from '#src/utils/test-utils.js'; /* eslint-enable import/no-duplicates */ -import dashboardRoutes from '@/routes/dashboard'; -import { createRequester } from '@/utils/test-utils'; +const { jest } = import.meta; const totalUserCount = 1000; -const countUsers = jest.fn(async () => ({ count: totalUserCount })); -const getDailyNewUserCountsByTimeInterval = jest.fn( - async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyNewUserCounts -); const formatToQueryDate = format('yyyy-MM-dd'); -jest.mock('@/queries/user', () => ({ - countUsers: async () => countUsers(), - getDailyNewUserCountsByTimeInterval: async ( - startTimeExclusive: number, - endTimeInclusive: number - ) => getDailyNewUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive), +const { countUsers, getDailyNewUserCountsByTimeInterval } = mockEsm('#src/queries/user.js', () => ({ + countUsers: jest.fn(async () => ({ count: totalUserCount })), + getDailyNewUserCountsByTimeInterval: jest.fn(async () => mockDailyNewUserCounts), })); const mockDailyNewUserCounts = [ @@ -44,21 +39,14 @@ const mockDailyActiveUserCounts = [ const mockActiveUserCount = 1000; -const getDailyActiveUserCountsByTimeInterval = jest.fn( - async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyActiveUserCounts +const { getDailyActiveUserCountsByTimeInterval, countActiveUsersByTimeInterval } = mockEsm( + '#src/queries/log.js', + () => ({ + getDailyActiveUserCountsByTimeInterval: jest.fn().mockResolvedValue(mockDailyActiveUserCounts), + countActiveUsersByTimeInterval: jest.fn().mockResolvedValue({ count: mockActiveUserCount }), + }) ); -const countActiveUsersByTimeInterval = jest.fn( - async (startTimeExclusive: number, endTimeInclusive: number) => ({ count: mockActiveUserCount }) -); - -jest.mock('@/queries/log', () => ({ - getDailyActiveUserCountsByTimeInterval: async ( - startTimeExclusive: number, - endTimeInclusive: number - ) => getDailyActiveUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive), - countActiveUsersByTimeInterval: async (startTimeExclusive: number, endTimeInclusive: number) => - countActiveUsersByTimeInterval(startTimeExclusive, endTimeInclusive), -})); +const dashboardRoutes = await pickDefault(import('./dashboard.js')); describe('dashboardRoutes', () => { const logRequest = createRequester({ authedRoutes: dashboardRoutes }); diff --git a/packages/core/src/routes/dashboard.ts b/packages/core/src/routes/dashboard.ts index e89247929..4b4ee5a00 100644 --- a/packages/core/src/routes/dashboard.ts +++ b/packages/core/src/routes/dashboard.ts @@ -2,14 +2,14 @@ import { dateRegex } from '@logto/core-kit'; import { endOfDay, format, subDays } from 'date-fns'; import { object, string } from 'zod'; -import koaGuard from '@/middleware/koa-guard'; +import koaGuard from '#src/middleware/koa-guard.js'; import { countActiveUsersByTimeInterval, getDailyActiveUserCountsByTimeInterval, -} from '@/queries/log'; -import { countUsers, getDailyNewUserCountsByTimeInterval } from '@/queries/user'; +} from '#src/queries/log.js'; +import { countUsers, getDailyNewUserCountsByTimeInterval } from '#src/queries/user.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; const getDateString = (date: Date | number) => format(date, 'yyyy-MM-dd'); diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 22240e263..474a586a8 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -4,32 +4,37 @@ import mount from 'koa-mount'; import Router from 'koa-router'; import type { Provider } from 'oidc-provider'; -import koaAuth from '@/middleware/koa-auth'; -import koaLogSession from '@/middleware/koa-log-session'; -import adminUserRoutes from '@/routes/admin-user'; -import applicationRoutes from '@/routes/application'; -import authnRoutes from '@/routes/authn'; -import connectorRoutes from '@/routes/connector'; -import customPhraseRoutes from '@/routes/custom-phrase'; -import dashboardRoutes from '@/routes/dashboard'; -import logRoutes from '@/routes/log'; -import phraseRoutes from '@/routes/phrase'; -import resourceRoutes from '@/routes/resource'; -import roleRoutes from '@/routes/role'; -import sessionRoutes from '@/routes/session'; -import settingRoutes from '@/routes/setting'; -import signInExperiencesRoutes from '@/routes/sign-in-experience'; -import statusRoutes from '@/routes/status'; -import swaggerRoutes from '@/routes/swagger'; -import wellKnownRoutes from '@/routes/well-known'; - -import type { AnonymousRouter, AuthedRouter } from './types'; +import koaAuth from '../middleware/koa-auth.js'; +import koaLogSession from '../middleware/koa-log-session.js'; +import adminUserRoutes from './admin-user.js'; +import applicationRoutes from './application.js'; +import authnRoutes from './authn.js'; +import connectorRoutes from './connector.js'; +import customPhraseRoutes from './custom-phrase.js'; +import dashboardRoutes from './dashboard.js'; +import interactionRoutes from './interaction/index.js'; +import logRoutes from './log.js'; +import phraseRoutes from './phrase.js'; +import profileRoutes from './profile.js'; +import resourceRoutes from './resource.js'; +import roleRoutes from './role.js'; +import sessionRoutes from './session/index.js'; +import settingRoutes from './setting.js'; +import signInExperiencesRoutes from './sign-in-experience.js'; +import statusRoutes from './status.js'; +import swaggerRoutes from './swagger.js'; +import type { AnonymousRouter, AuthedRouter } from './types.js'; +import wellKnownRoutes from './well-known.js'; const createRouters = (provider: Provider) => { const sessionRouter: AnonymousRouter = new Router(); sessionRouter.use(koaLogSession(provider)); sessionRoutes(sessionRouter, provider); + const interactionRouter: AnonymousRouter = new Router(); + interactionRouter.use(koaLogSession(provider)); + interactionRoutes(interactionRouter, provider); + const managementRouter: AuthedRouter = new Router(); managementRouter.use(koaAuth(UserRole.Admin)); applicationRoutes(managementRouter); @@ -43,15 +48,24 @@ const createRouters = (provider: Provider) => { dashboardRoutes(managementRouter); customPhraseRoutes(managementRouter); + const profileRouter: AnonymousRouter = new Router(); + profileRoutes(profileRouter, provider); + const anonymousRouter: AnonymousRouter = new Router(); phraseRoutes(anonymousRouter, provider); wellKnownRoutes(anonymousRouter, provider); statusRoutes(anonymousRouter); authnRoutes(anonymousRouter); // The swagger.json should contain all API routers. - swaggerRoutes(anonymousRouter, [sessionRouter, managementRouter, anonymousRouter]); + swaggerRoutes(anonymousRouter, [ + sessionRouter, + interactionRouter, + profileRouter, + managementRouter, + anonymousRouter, + ]); - return [sessionRouter, managementRouter, anonymousRouter]; + return [sessionRouter, interactionRouter, profileRouter, managementRouter, anonymousRouter]; }; export default function initRouter(app: Koa, provider: Provider) { diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts new file mode 100644 index 000000000..878e7c815 --- /dev/null +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -0,0 +1,155 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; + +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { + Identifier, + VerifiedRegisterInteractionResult, + InteractionContext, + VerifiedSignInInteractionResult, + VerifiedForgotPasswordInteractionResult, +} from '../types/index.js'; + +const { jest } = import.meta; + +const { getLogtoConnectorById } = mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest + .fn() + .mockResolvedValue({ metadata: { target: 'logto' }, dbEntry: { syncProfile: true } }), +})); + +const { assignInteractionResults } = mockEsm('#src/lib/session.js', () => ({ + assignInteractionResults: jest.fn(), +})); + +const { encryptUserPassword, generateUserId, insertUser } = mockEsm('#src/lib/user.js', () => ({ + encryptUserPassword: jest.fn().mockResolvedValue({ + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + }), + generateUserId: jest.fn().mockResolvedValue('uid'), + insertUser: jest.fn(), +})); + +mockEsm('#src/queries/user.js', () => ({ + findUserById: jest + .fn() + .mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }), + updateUserById: jest.fn(), +})); + +const { updateUserById } = await import('#src/queries/user.js'); +const submitInteraction = await pickDefault(import('./submit-interaction.js')); +const now = Date.now(); + +jest.useFakeTimers().setSystemTime(now); + +describe('submit action', () => { + const provider = createMockProvider(); + const log = jest.fn(); + const ctx: InteractionContext = { + ...createContextWithRouteParameters(), + log, + interactionPayload: { event: Event.SignIn }, + }; + const profile = { + username: 'username', + password: 'password', + phone: '123456', + email: 'email@logto.io', + connectorId: 'logto', + }; + + const userInfo = { id: 'foo', name: 'foo_social', avatar: 'avatar' }; + const identifiers: Identifier[] = [ + { + key: 'social', + connectorId: 'logto', + userInfo, + }, + ]; + + const upsertProfile = { + username: 'username', + primaryPhone: '123456', + primaryEmail: 'email@logto.io', + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + identities: { + logto: { userId: userInfo.id, details: userInfo }, + }, + name: userInfo.name, + avatar: userInfo.avatar, + lastSignInAt: now, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('register', async () => { + const interaction: VerifiedRegisterInteractionResult = { + event: Event.Register, + profile, + identifiers, + }; + + await submitInteraction(interaction, ctx, provider); + + expect(generateUserId).toBeCalled(); + expect(encryptUserPassword).toBeCalledWith('password'); + expect(getLogtoConnectorById).toBeCalledWith('logto'); + + expect(insertUser).toBeCalledWith({ + id: 'uid', + ...upsertProfile, + }); + expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'uid' } }); + }); + + it('sign-in', async () => { + getLogtoConnectorById.mockResolvedValueOnce({ + metadata: { target: 'logto' }, + dbEntry: { syncProfile: false }, + }); + const interaction: VerifiedSignInInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + profile: { connectorId: 'logto', password: 'password' }, + identifiers, + }; + + await submitInteraction(interaction, ctx, provider); + + expect(encryptUserPassword).toBeCalledWith('password'); + expect(getLogtoConnectorById).toBeCalledWith('logto'); + + expect(updateUserById).toBeCalledWith('foo', { + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + identities: { + logto: { userId: userInfo.id, details: userInfo }, + google: { userId: 'googleId', details: {} }, + }, + lastSignInAt: now, + }); + expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'foo' } }); + }); + + it('reset password', async () => { + const interaction: VerifiedForgotPasswordInteractionResult = { + event: Event.ForgotPassword, + accountId: 'foo', + profile: { password: 'password' }, + }; + await submitInteraction(interaction, ctx, provider); + expect(encryptUserPassword).toBeCalledWith('password'); + expect(updateUserById).toBeCalledWith('foo', { + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + }); + expect(assignInteractionResults).not.toBeCalled(); + }); +}); diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts new file mode 100644 index 000000000..47ae8fdb6 --- /dev/null +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -0,0 +1,130 @@ +import type { User } from '@logto/schemas'; +import { Event } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import type { Provider } from 'oidc-provider'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import { assignInteractionResults } from '#src/lib/session.js'; +import { encryptUserPassword, generateUserId, insertUser } from '#src/lib/user.js'; +import { findUserById, updateUserById } from '#src/queries/user.js'; + +import type { + InteractionContext, + Identifier, + VerifiedInteractionResult, + SocialIdentifier, + VerifiedSignInInteractionResult, + VerifiedRegisterInteractionResult, +} from '../types/index.js'; +import { clearInteractionStorage } from '../utils/interaction.js'; + +const getSocialUpdateProfile = async ({ + user, + connectorId, + identifiers, +}: { + user?: User; + connectorId: string; + identifiers?: Identifier[]; +}) => { + // TODO: @simeng refactor me. This step should be verified by the previous profile verification cycle Already. + // Should pickup the verified social user info result automatically + const socialIdentifier = identifiers?.find( + (identifier): identifier is SocialIdentifier => + identifier.key === 'social' && identifier.connectorId === connectorId + ); + + if (!socialIdentifier) { + return; + } + + const { + metadata: { target }, + dbEntry: { syncProfile }, + } = await getLogtoConnectorById(connectorId); + + const { userInfo } = socialIdentifier; + const { name, avatar, id } = userInfo; + + const profileUpdate = conditional( + (syncProfile || !user) && { + ...conditional(name && { name }), + ...conditional(avatar && { avatar }), + } + ); + + return { + identities: { ...user?.identities, [target]: { userId: id, details: userInfo } }, + ...profileUpdate, + }; +}; + +const parseUserProfile = async ( + { profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult, + user?: User +) => { + if (!profile) { + return; + } + + const { phone, username, email, connectorId, password } = profile; + + const [passwordProfile, socialProfile] = await Promise.all([ + conditional(password && (await encryptUserPassword(password))), + conditional(connectorId && (await getSocialUpdateProfile({ connectorId, identifiers, user }))), + ]); + + return { + ...conditional(phone && { primaryPhone: phone }), + ...conditional(username && { username }), + ...conditional(email && { primaryEmail: email }), + ...passwordProfile, + ...socialProfile, + lastSignInAt: Date.now(), + }; +}; + +export default async function submitInteraction( + interaction: VerifiedInteractionResult, + ctx: InteractionContext, + provider: Provider +) { + const { event, profile } = interaction; + + if (event === Event.Register) { + const id = await generateUserId(); + const upsertProfile = await parseUserProfile(interaction); + + await insertUser({ + id, + ...upsertProfile, + }); + + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); + + return; + } + + const { accountId } = interaction; + + if (event === Event.SignIn) { + const user = await findUserById(accountId); + const upsertProfile = await parseUserProfile(interaction, user); + + if (upsertProfile) { + await updateUserById(accountId, upsertProfile); + } + + await assignInteractionResults(ctx, provider, { login: { accountId } }); + + return; + } + + // Forgot Password + const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword( + profile.password + ); + await updateUserById(accountId, { passwordEncrypted, passwordEncryptionMethod }); + await clearInteractionStorage(ctx, provider); + ctx.status = 204; +} diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts new file mode 100644 index 000000000..7751beda7 --- /dev/null +++ b/packages/core/src/routes/interaction/index.test.ts @@ -0,0 +1,300 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { Event } from '@logto/schemas'; +import { demoAppApplicationId } from '@logto/schemas/lib/seeds/application.js'; +import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +import type { InteractionContext } from './types/index.js'; + +const { jest } = import.meta; + +// FIXME @Darcy: no more `enabled` for `connectors` table +const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => { + const metadata = { + id: + connectorId === 'social_enabled' + ? 'social_enabled' + : connectorId === 'social_disabled' + ? 'social_disabled' + : 'others', + }; + + return { + dbEntry: {}, + metadata, + type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms, + getAuthorizationUri: jest.fn(async () => ''), + }; +}); + +await mockEsmWithActual('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest.fn(async (connectorId: string) => { + const connector = await getLogtoConnectorByIdHelper(connectorId); + + if (connector.type !== ConnectorType.Social) { + throw new RequestError({ + code: 'entity.not_found', + status: 404, + }); + } + + return connector; + }), +})); + +const { sendPasscodeToIdentifier } = await mockEsmWithActual( + './utils/passcode-validation.js', + () => ({ + sendPasscodeToIdentifier: jest.fn(), + }) +); + +mockEsm('#src/lib/sign-in-experience/index.js', () => ({ + getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience), +})); + +const { verifyIdentifier, verifyProfile, validateMandatoryUserProfile } = mockEsm( + './verifications/index.js', + () => ({ + verifyIdentifier: jest.fn(), + verifyProfile: jest.fn(), + validateMandatoryUserProfile: jest.fn(), + }) +); + +const { default: submitInteraction } = mockEsm('./actions/submit-interaction.js', () => ({ + default: jest.fn((_interaction, ctx: InteractionContext) => { + ctx.body = { redirectUri: 'logto.io' }; + }), +})); + +const { getInteractionStorage } = mockEsm('./utils/interaction.js', () => ({ + getInteractionStorage: jest.fn(), +})); + +const log = jest.fn(); + +const koaInteractionBodyGuard = await pickDefault( + import('./middleware/koa-interaction-body-guard.js') +); +const koaSessionSignInExperienceGuard = await pickDefault( + import('./middleware/koa-session-sign-in-experience-guard.js') +); + +const koaInteractionBodyGuardSpy = mockEsmDefault( + './middleware/koa-interaction-body-guard.js', + () => jest.fn(koaInteractionBodyGuard) +); + +const koaSessionSignInExperienceGuardSpy = mockEsmDefault( + './middleware/koa-session-sign-in-experience-guard.js', + () => jest.fn(koaSessionSignInExperienceGuard) +); + +const { + default: interactionRoutes, + verificationPrefix, + interactionPrefix, +} = await import('./index.js'); + +describe('session -> interactionRoutes', () => { + const sessionRequest = createRequester({ + anonymousRoutes: interactionRoutes, + provider: createMockProvider( + jest.fn().mockResolvedValue({ params: {}, jti: 'jti', client_id: demoAppApplicationId }) + ), + middlewares: [ + async (ctx, next) => { + ctx.addLogContext = jest.fn(); + ctx.log = log; + + return next(); + }, + ], + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('PUT /interaction', () => { + const path = interactionPrefix; + + it('sign-in event should call methods properly', async () => { + const body = { + event: Event.SignIn, + identifier: { + username: 'username', + password: 'password', + }, + }; + const response = await sessionRequest.put(path).send(body); + expect(koaInteractionBodyGuardSpy).toBeCalled(); + expect(koaSessionSignInExperienceGuardSpy).toBeCalled(); + expect(verifyIdentifier).toBeCalled(); + expect(verifyProfile).toBeCalled(); + expect(validateMandatoryUserProfile).toBeCalled(); + expect(submitInteraction).toBeCalled(); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ redirectUri: 'logto.io' }); + }); + + it('forgot password event should not call UserProfile validation', async () => { + const body = { + event: Event.ForgotPassword, + identifier: { + email: 'email@logto.io', + passcode: 'passcode', + }, + profile: { + password: 'password', + }, + }; + + const response = await sessionRequest.put(path).send(body); + + expect(verifyIdentifier).toBeCalled(); + expect(verifyProfile).toBeCalled(); + expect(validateMandatoryUserProfile).not.toBeCalled(); + expect(submitInteraction).toBeCalled(); + expect(response.status).toEqual(200); + }); + }); + + describe('PATCH /interaction', () => { + const path = interactionPrefix; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sign-in event with register event interaction session in record should call methods properly', async () => { + getInteractionStorage.mockResolvedValueOnce({ event: Event.Register }); + + const body = { + event: Event.SignIn, + }; + + const response = await sessionRequest.patch(path).send(body); + expect(verifyIdentifier).toBeCalled(); + expect(verifyProfile).toBeCalled(); + expect(validateMandatoryUserProfile).toBeCalled(); + expect(submitInteraction).toBeCalled(); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ redirectUri: 'logto.io' }); + }); + + it('sign-in event with forgot password event interaction session in record should reject', async () => { + getInteractionStorage.mockResolvedValueOnce({ event: Event.ForgotPassword }); + + const body = { + event: Event.SignIn, + }; + + const response = await sessionRequest.patch(path).send(body); + expect(verifyIdentifier).not.toBeCalled(); + expect(verifyProfile).not.toBeCalled(); + expect(validateMandatoryUserProfile).not.toBeCalled(); + expect(submitInteraction).not.toBeCalled(); + expect(response.status).toEqual(404); + }); + + it('Forgot event with forgot password event interaction session in record should call methods properly', async () => { + getInteractionStorage.mockResolvedValueOnce({ event: Event.ForgotPassword }); + + const body = { + event: Event.ForgotPassword, + }; + + const response = await sessionRequest.patch(path).send(body); + expect(verifyIdentifier).toBeCalled(); + expect(verifyProfile).toBeCalled(); + expect(validateMandatoryUserProfile).not.toBeCalled(); + expect(submitInteraction).toBeCalled(); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ redirectUri: 'logto.io' }); + }); + + it('Forgot event with sign-in event interaction session in record should call methods properly', async () => { + getInteractionStorage.mockResolvedValueOnce({ event: Event.SignIn }); + + const body = { + event: Event.ForgotPassword, + }; + + const response = await sessionRequest.patch(path).send(body); + expect(verifyIdentifier).not.toBeCalled(); + expect(verifyProfile).not.toBeCalled(); + expect(validateMandatoryUserProfile).not.toBeCalled(); + expect(submitInteraction).not.toBeCalled(); + expect(response.status).toEqual(404); + }); + }); + + describe('POST /verification/passcode', () => { + const path = `${verificationPrefix}/passcode`; + it('should call send passcode properly', async () => { + const body = { + event: Event.SignIn, + email: 'email@logto.io', + }; + + const response = await sessionRequest.post(path).send(body); + expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', log); + expect(response.status).toEqual(204); + }); + }); + + describe('POST /verification/social/authorization-uri', () => { + const path = `${verificationPrefix}/social/authorization-uri`; + + it('should throw when redirectURI is invalid', async () => { + const response = await sessionRequest.post(path).send({ + connectorId: 'social_enabled', + state: 'state', + redirectUri: 'logto.dev', + }); + expect(response.statusCode).toEqual(400); + }); + + it('should return the authorization-uri properly', async () => { + const response = await sessionRequest.post(path).send({ + connectorId: 'social_enabled', + state: 'state', + redirectUri: 'https://logto.dev', + }); + + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo', ''); + }); + + it('throw error when sign-in with social but miss state', async () => { + const response = await sessionRequest.post(path).send({ + connectorId: 'social_enabled', + redirectUri: 'https://logto.dev', + }); + expect(response.statusCode).toEqual(400); + }); + + it('throw error when sign-in with social but miss redirectUri', async () => { + const response = await sessionRequest.post(path).send({ + connectorId: 'social_enabled', + state: 'state', + }); + expect(response.statusCode).toEqual(400); + }); + + it('throw error when no social connector is found', async () => { + const response = await sessionRequest.post(path).send({ + connectorId: 'others', + state: 'state', + redirectUri: 'https://logto.dev', + }); + expect(response.statusCode).toEqual(404); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts new file mode 100644 index 000000000..4bfbd051d --- /dev/null +++ b/packages/core/src/routes/interaction/index.ts @@ -0,0 +1,127 @@ +import type { LogtoErrorCode } from '@logto/phrases'; +import { Event } from '@logto/schemas'; +import type { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults } from '#src/lib/session.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { AnonymousRouter } from '../types.js'; +import submitInteraction from './actions/submit-interaction.js'; +import koaInteractionBodyGuard from './middleware/koa-interaction-body-guard.js'; +import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-experience-guard.js'; +import { sendPasscodePayloadGuard, getSocialAuthorizationUrlPayloadGuard } from './types/guard.js'; +import { getInteractionStorage } from './utils/interaction.js'; +import { sendPasscodeToIdentifier } from './utils/passcode-validation.js'; +import { createSocialAuthorizationUrl } from './utils/social-verification.js'; +import { + verifyIdentifier, + verifyProfile, + validateMandatoryUserProfile, +} from './verifications/index.js'; + +export const interactionPrefix = '/interaction'; +export const verificationPrefix = '/verification'; + +export default function interactionRoutes( + router: T, + provider: Provider +) { + router.put( + interactionPrefix, + koaInteractionBodyGuard(), + koaSessionSignInExperienceGuard(provider), + async (ctx, next) => { + const { event } = ctx.interactionPayload; + + // Check interaction session + await provider.interactionDetails(ctx.req, ctx.res); + + const identifierVerifiedInteraction = await verifyIdentifier(ctx, provider); + + const interaction = await verifyProfile(ctx, provider, identifierVerifiedInteraction); + + if (event !== Event.ForgotPassword) { + await validateMandatoryUserProfile(ctx, interaction); + } + + await submitInteraction(interaction, ctx, provider); + + return next(); + } + ); + + router.patch( + interactionPrefix, + koaInteractionBodyGuard(), + koaSessionSignInExperienceGuard(provider), + async (ctx, next) => { + const { event } = ctx.interactionPayload; + const interactionStorage = await getInteractionStorage(ctx, provider); + + // Forgot Password specific event interaction can't be shared with other types of interactions + assertThat( + event === Event.ForgotPassword + ? interactionStorage.event === Event.ForgotPassword + : interactionStorage.event !== Event.ForgotPassword, + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + + const identifierVerifiedInteraction = await verifyIdentifier( + ctx, + provider, + interactionStorage + ); + + const interaction = await verifyProfile(ctx, provider, identifierVerifiedInteraction); + + if (event !== Event.ForgotPassword) { + await validateMandatoryUserProfile(ctx, interaction); + } + + await submitInteraction(interaction, ctx, provider); + + return next(); + } + ); + + router.delete(interactionPrefix, async (ctx, next) => { + await provider.interactionDetails(ctx.req, ctx.res); + const error: LogtoErrorCode = 'oidc.aborted'; + await assignInteractionResults(ctx, provider, { error }); + + return next(); + }); + + router.post( + `${verificationPrefix}/social/authorization-uri`, + koaGuard({ body: getSocialAuthorizationUrlPayloadGuard }), + async (ctx, next) => { + // Check interaction session + await provider.interactionDetails(ctx.req, ctx.res); + + const redirectTo = await createSocialAuthorizationUrl(ctx.guard.body); + + ctx.body = { redirectTo }; + + return next(); + } + ); + + router.post( + `${verificationPrefix}/passcode`, + koaGuard({ + body: sendPasscodePayloadGuard, + }), + async (ctx, next) => { + // Check interaction session + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.log); + + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts new file mode 100644 index 000000000..1be87b4cc --- /dev/null +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts @@ -0,0 +1,150 @@ +import { Event } from '@logto/schemas'; +import { mockEsmDefault, pickDefault } from '@logto/shared/esm'; +import type { Context } from 'koa'; + +import { interactionMocks } from '#src/__mocks__/interactions.js'; +import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js'; + +const { jest } = import.meta; + +mockEsmDefault('koa-body', () => emptyMiddleware); + +const koaInteractionBodyGuard = await pickDefault(import('./koa-interaction-body-guard.js')); + +describe('koaInteractionBodyGuard', () => { + const baseCtx = createContextWithRouteParameters(); + const next = jest.fn(); + + describe('event', () => { + it('invalid event should throw', async () => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: 'test', + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + + await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow(); + }); + + it.each([Event.SignIn, Event.ForgotPassword])('%p should parse successfully', async (event) => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + + await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); + expect(ctx.interactionPayload.event).toEqual(event); + }); + + it('register should parse successfully', async () => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: Event.Register, + profile: { username: 'username', password: 'password' }, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + + await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); + expect(ctx.interactionPayload.event).toEqual(Event.Register); + }); + }); + + describe('identifier', () => { + it('invalid identifier should throw', async () => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: Event.SignIn, + identifier: { + username: 'username', + passcode: 'passcode', + }, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + + await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow(); + }); + + it.each(interactionMocks)('interaction methods should parse successfully', async (input) => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: Event.SignIn, + identifier: input, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + + await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); + expect(ctx.interactionPayload.identifier).toEqual(input); + }); + }); + + describe('profile', () => { + it('invalid profile format should throw', async () => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: Event.SignIn, + profile: { + email: 'username', + }, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow(); + }); + + it('profile should resolve properly', async () => { + const profile = { + email: 'foo@logto.io', + phone: '123123', + username: 'username', + password: '123456', + connectorId: 'connectorId', + }; + + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: Event.SignIn, + profile, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); + expect(ctx.interactionPayload.profile).toEqual(profile); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts new file mode 100644 index 000000000..b93ae33f0 --- /dev/null +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.ts @@ -0,0 +1,37 @@ +import type { MiddlewareType } from 'koa'; +import koaBody from 'koa-body'; + +import RequestError from '#src/errors/RequestError/index.js'; + +import { interactionPayloadGuard } from '../types/guard.js'; +import type { InteractionPayload } from '../types/index.js'; + +export type WithGuardedIdentifierPayloadContext = ContextT & { + interactionPayload: InteractionPayload; +}; + +const parse = (data: unknown) => { + try { + return interactionPayloadGuard.parse(data); + } catch (error: unknown) { + throw new RequestError({ code: 'guard.invalid_input' }, error); + } +}; + +/** + * Need this as our koaGuard does not infer the body type properly + * from the ZodEffects Output after data transform + */ +export default function koaInteractionBodyGuard(): MiddlewareType< + StateT, + WithGuardedIdentifierPayloadContext, + ResponseT +> { + return async (ctx, next) => { + return koaBody()(ctx, async () => { + ctx.interactionPayload = parse(ctx.request.body); + + return next(); + }); + }; +} diff --git a/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts b/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts new file mode 100644 index 000000000..fbcbd604f --- /dev/null +++ b/packages/core/src/routes/interaction/middleware/koa-session-sign-in-experience-guard.ts @@ -0,0 +1,53 @@ +import type { SignInExperience } from '@logto/schemas'; +import { Event } from '@logto/schemas'; +import type { MiddlewareType } from 'koa'; +import type { IRouterParamContext } from 'koa-router'; +import type { Provider } from 'oidc-provider'; + +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; + +import { + signInModeValidation, + identifierValidation, + profileValidation, +} from '../utils/sign-in-experience-validation.js'; +import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js'; + +export type WithSignInExperienceContext = ContextT & { + signInExperience: SignInExperience; +}; + +export default function koaSessionSignInExperienceGuard< + StateT, + ContextT extends WithGuardedIdentifierPayloadContext, + ResponseBodyT +>( + provider: Provider +): MiddlewareType, ResponseBodyT> { + return async (ctx, next) => { + const interaction = await provider.interactionDetails(ctx.req, ctx.res); + const { event, identifier, profile } = ctx.interactionPayload; + + if (event === Event.ForgotPassword) { + return next(); + } + + const signInExperience = await getSignInExperienceForApplication( + typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined + ); + + signInModeValidation(event, signInExperience); + + if (identifier) { + identifierValidation(identifier, signInExperience); + } + + if (profile) { + profileValidation(profile, signInExperience); + } + + ctx.signInExperience = signInExperience; + + return next(); + }; +} diff --git a/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts b/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts new file mode 100644 index 000000000..3633e81a8 --- /dev/null +++ b/packages/core/src/routes/interaction/middleware/koa-session-sign-inexperience-guard.test.ts @@ -0,0 +1,51 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +mockEsm('#src/lib/sign-in-experience/index.js', () => ({ + getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience), +})); + +const mockUtils = { + signInModeValidation: jest.fn(), + identifierValidation: jest.fn(), + profileValidation: jest.fn(), +}; + +mockEsm('../utils/sign-in-experience-validation.js', () => mockUtils); + +const koaSessionSignInExperienceGuard = await pickDefault( + import('./koa-session-sign-in-experience-guard.js') +); + +describe('koaSessionSignInExperienceGuard', () => { + const baseCtx = createContextWithRouteParameters(); + const next = jest.fn(); + + it('should call validation method properly', async () => { + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: { username: 'username', password: 'password' }, + profile: { email: 'email' }, + }), + signInExperience: mockSignInExperience, + }; + const provider = createMockProvider(); + + await koaSessionSignInExperienceGuard(provider)(ctx, next); + + expect(mockUtils.signInModeValidation).toBeCalledWith(Event.SignIn, mockSignInExperience); + expect(mockUtils.identifierValidation).toBeCalledWith( + { username: 'username', password: 'password' }, + mockSignInExperience + ); + expect(mockUtils.profileValidation).toBeCalledWith({ email: 'email' }, mockSignInExperience); + }); +}); diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts new file mode 100644 index 000000000..e0559885d --- /dev/null +++ b/packages/core/src/routes/interaction/types/guard.ts @@ -0,0 +1,129 @@ +import { emailRegEx, phoneRegEx, validateRedirectUrl } from '@logto/core-kit'; +import { + usernamePasswordPayloadGuard, + emailPasscodePayloadGuard, + phonePasscodePayloadGuard, + socialConnectorPayloadGuard, + eventGuard, + profileGuard, + identifierPayloadGuard, + Event, +} from '@logto/schemas'; +import { z } from 'zod'; + +import { socialUserInfoGuard } from '#src/connectors/types.js'; + +// Interaction Payload Guard +const forgotPasswordInteractionPayloadGuard = z.object({ + event: z.literal(Event.ForgotPassword), + identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(), + profile: z.object({ password: z.string() }).optional(), +}); + +const registerInteractionPayloadGuard = z.object({ + event: z.literal(Event.Register), + identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(), + profile: profileGuard, +}); + +const signInInteractionPayloadGuard = z.object({ + event: z.literal(Event.SignIn), + identifier: identifierPayloadGuard.optional(), + profile: profileGuard.optional(), +}); + +export const interactionPayloadGuard = z.discriminatedUnion('event', [ + signInInteractionPayloadGuard, + registerInteractionPayloadGuard, + forgotPasswordInteractionPayloadGuard, +]); + +// Passcode Send Route Payload Guard +export const sendPasscodePayloadGuard = z.union([ + z.object({ + event: eventGuard, + email: z.string().regex(emailRegEx), + }), + z.object({ + event: eventGuard, + phone: z.string().regex(phoneRegEx), + }), +]); + +// Social Authorization Uri Route Payload Guard +export const getSocialAuthorizationUrlPayloadGuard = z.object({ + connectorId: z.string(), + state: z.string(), + redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')), +}); + +// Register Profile Guard +const emailProfileGuard = emailPasscodePayloadGuard.pick({ email: true }); +const phoneProfileGuard = phonePasscodePayloadGuard.pick({ phone: true }); +const socialProfileGuard = socialConnectorPayloadGuard.pick({ connectorId: true }); + +export const registerProfileSafeGuard = z.union([ + usernamePasswordPayloadGuard.merge(profileGuard.omit({ username: true, password: true })), + emailProfileGuard.merge(profileGuard.omit({ email: true })), + phoneProfileGuard.merge(profileGuard.omit({ phone: true })), + socialProfileGuard.merge(profileGuard.omit({ connectorId: true })), +]); + +// Identifier Guard +export const accountIdIdentifierGuard = z.object({ + key: z.literal('accountId'), + value: z.string(), +}); + +export const verifiedEmailIdentifierGuard = z.object({ + key: z.literal('emailVerified'), + value: z.string(), +}); + +export const verifiedPhoneIdentifierGuard = z.object({ + key: z.literal('phoneVerified'), + value: z.string(), +}); + +export const socialIdentifierGuard = z.object({ + key: z.literal('social'), + connectorId: z.string(), + userInfo: socialUserInfoGuard, +}); + +export const identifierGuard = z.discriminatedUnion('key', [ + accountIdIdentifierGuard, + verifiedEmailIdentifierGuard, + verifiedPhoneIdentifierGuard, + socialIdentifierGuard, +]); + +export const anonymousInteractionResultGuard = z.object({ + event: eventGuard, + profile: profileGuard.optional(), + accountId: z.string().optional(), + identifiers: z.array(identifierGuard).optional(), +}); + +export const verifiedRegisterInteractionResultGuard = z.object({ + event: z.literal(Event.Register), + profile: registerProfileSafeGuard, + identifiers: z.array(identifierGuard).optional(), +}); + +export const verifiedSignInteractionResultGuard = z.object({ + event: z.literal(Event.SignIn), + accountId: z.string(), + profile: profileGuard.optional(), + identifiers: z.array(identifierGuard).optional(), +}); + +export const forgotPasswordProfileGuard = z.object({ + password: z.string(), +}); + +export const verifiedForgotPasswordInteractionResultGuard = z.object({ + event: z.literal(Event.ForgotPassword), + accountId: z.string(), + profile: forgotPasswordProfileGuard, +}); diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts new file mode 100644 index 000000000..8ff0f49e8 --- /dev/null +++ b/packages/core/src/routes/interaction/types/index.ts @@ -0,0 +1,117 @@ +import type { + UsernamePasswordPayload, + EmailPasswordPayload, + EmailPasscodePayload, + PhonePasswordPayload, + PhonePasscodePayload, + Event, +} from '@logto/schemas'; +import type { Context } from 'koa'; +import type { IRouterParamContext } from 'koa-router'; +import type { z } from 'zod'; + +import type { SocialUserInfo } from '#src/connectors/types.js'; + +import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js'; +import type { + interactionPayloadGuard, + sendPasscodePayloadGuard, + getSocialAuthorizationUrlPayloadGuard, + accountIdIdentifierGuard, + verifiedEmailIdentifierGuard, + verifiedPhoneIdentifierGuard, + socialIdentifierGuard, + identifierGuard, + anonymousInteractionResultGuard, + verifiedRegisterInteractionResultGuard, + verifiedSignInteractionResultGuard, + verifiedForgotPasswordInteractionResultGuard, + registerProfileSafeGuard, + forgotPasswordProfileGuard, +} from './guard.js'; + +// Payload Types +export type InteractionPayload = z.infer; + +export type PasswordIdentifierPayload = + | UsernamePasswordPayload + | EmailPasswordPayload + | PhonePasswordPayload; + +export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload; + +export type SendPasscodePayload = z.infer; + +export type SocialAuthorizationUrlPayload = z.infer; + +// Interaction Types +export type AccountIdIdentifier = z.infer; + +export type VerifiedEmailIdentifier = z.infer; + +export type VerifiedPhoneIdentifier = z.infer; + +export type SocialIdentifier = z.infer; + +export type Identifier = z.infer; + +export type AnonymousInteractionResult = z.infer; + +export type RegisterSafeProfile = z.infer; + +export type ForgotPasswordProfile = z.infer; + +export type VerifiedRegisterInteractionResult = z.infer< + typeof verifiedRegisterInteractionResultGuard +>; + +export type VerifiedSignInInteractionResult = z.infer; + +export type VerifiedForgotPasswordInteractionResult = z.infer< + typeof verifiedForgotPasswordInteractionResultGuard +>; + +export type RegisterInteractionResult = Omit & { + event: Event.Register; +}; + +export type SignInInteractionResult = Omit & { + event: Event.SignIn; +}; + +export type ForgotPasswordInteractionResult = Omit & { + event: Event.ForgotPassword; +}; + +export type PreAccountVerifiedInteractionResult = + | SignInInteractionResult + | ForgotPasswordInteractionResult; + +export type PayloadVerifiedInteractionResult = + | RegisterInteractionResult + | PreAccountVerifiedInteractionResult; + +export type AccountVerifiedInteractionResult = + | (Omit & { + accountId: string; + }) + | (Omit & { + accountId: string; + }); + +export type IdentifierVerifiedInteractionResult = + | RegisterInteractionResult + | AccountVerifiedInteractionResult; + +export type VerifiedInteractionResult = + | VerifiedRegisterInteractionResult + | VerifiedSignInInteractionResult + | VerifiedForgotPasswordInteractionResult; + +export type InteractionContext = WithGuardedIdentifierPayloadContext; + +export type UserIdentity = + | { username: string } + | { email: string } + | { phone: string } + | { connectorId: string; userInfo: SocialUserInfo }; diff --git a/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts b/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts new file mode 100644 index 000000000..3ac8af3a6 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts @@ -0,0 +1,41 @@ +import { mockEsm, pickDefault } from '@logto/shared/esm'; + +const { jest } = import.meta; + +const queries = { + findUserByEmail: jest.fn(), + findUserByUsername: jest.fn(), + findUserByPhone: jest.fn(), + findUserByIdentity: jest.fn(), +}; + +mockEsm('#src/queries/user.js', () => queries); + +const { getLogtoConnectorById } = mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest.fn().mockResolvedValue({ metadata: { target: 'logto' } }), +})); + +const findUserByIdentifier = await pickDefault(import('./find-user-by-identifier.js')); + +describe('findUserByIdentifier', () => { + it('username', async () => { + await findUserByIdentifier({ username: 'foo' }); + expect(queries.findUserByUsername).toBeCalledWith('foo'); + }); + + it('email', async () => { + await findUserByIdentifier({ email: 'foo@logto.io' }); + expect(queries.findUserByEmail).toBeCalledWith('foo@logto.io'); + }); + + it('phone', async () => { + await findUserByIdentifier({ phone: '123456' }); + expect(queries.findUserByPhone).toBeCalledWith('123456'); + }); + + it('social', async () => { + await findUserByIdentifier({ connectorId: 'connector', userInfo: { id: 'foo' } }); + expect(getLogtoConnectorById).toBeCalledWith('connector'); + expect(queries.findUserByIdentity).toBeCalledWith('logto', 'foo'); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts b/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts new file mode 100644 index 000000000..2399cb312 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts @@ -0,0 +1,33 @@ +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import { + findUserByEmail, + findUserByUsername, + findUserByPhone, + findUserByIdentity, +} from '#src/queries/user.js'; + +import type { UserIdentity } from '../types/index.js'; + +export default async function findUserByIdentifier(identity: UserIdentity) { + if ('username' in identity) { + return findUserByUsername(identity.username); + } + + if ('email' in identity) { + return findUserByEmail(identity.email); + } + + if ('phone' in identity) { + return findUserByPhone(identity.phone); + } + + if ('connectorId' in identity) { + const { + metadata: { target }, + } = await getLogtoConnectorById(identity.connectorId); + + return findUserByIdentity(target, identity.userInfo.id); + } + + return null; +} diff --git a/packages/core/src/routes/interaction/utils/index.ts b/packages/core/src/routes/interaction/utils/index.ts new file mode 100644 index 000000000..efaa83e29 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/index.ts @@ -0,0 +1,24 @@ +import type { SocialConnectorPayload, User, IdentifierPayload } from '@logto/schemas'; + +import type { PasscodeIdentifierPayload, PasswordIdentifierPayload } from '../types/index.js'; + +export const isPasswordIdentifier = ( + identifier: IdentifierPayload +): identifier is PasswordIdentifierPayload => 'password' in identifier; + +export const isPasscodeIdentifier = ( + identifier: IdentifierPayload +): identifier is PasscodeIdentifierPayload => 'passcode' in identifier; + +export const isSocialIdentifier = ( + identifier: IdentifierPayload +): identifier is SocialConnectorPayload => + 'connectorId' in identifier && 'connectorData' in identifier; + +// Social identities can take place the role of password +export const isUserPasswordSet = ({ + passwordEncrypted, + identities, +}: Pick): boolean => { + return Boolean(passwordEncrypted) || Object.keys(identities).length > 0; +}; diff --git a/packages/core/src/routes/interaction/utils/interaction.test.ts b/packages/core/src/routes/interaction/utils/interaction.test.ts new file mode 100644 index 000000000..6d0a8858b --- /dev/null +++ b/packages/core/src/routes/interaction/utils/interaction.test.ts @@ -0,0 +1,42 @@ +import type { Identifier } from '../types/index.js'; +import { mergeIdentifiers } from './interaction.js'; + +describe('interaction utils', () => { + const usernameIdentifier: Identifier = { key: 'accountId', value: 'foo' }; + const emailIdentifier: Identifier = { key: 'emailVerified', value: 'foo@logto.io' }; + const phoneIdentifier: Identifier = { key: 'phoneVerified', value: '12346' }; + + describe('mergeIdentifiers', () => { + it('new identifiers only ', () => { + expect(mergeIdentifiers([usernameIdentifier])).toEqual([usernameIdentifier]); + }); + + it('same identifiers should replace', () => { + expect(mergeIdentifiers([usernameIdentifier], [{ key: 'accountId', value: 'foo2' }])).toEqual( + [usernameIdentifier] + ); + }); + + it('different identifiers should merge', () => { + expect(mergeIdentifiers([emailIdentifier], [usernameIdentifier])).toEqual([ + usernameIdentifier, + emailIdentifier, + ]); + + expect(mergeIdentifiers([usernameIdentifier], [emailIdentifier, phoneIdentifier])).toEqual([ + emailIdentifier, + phoneIdentifier, + usernameIdentifier, + ]); + }); + + it('mixed identifiers should replace and merge', () => { + expect( + mergeIdentifiers( + [phoneIdentifier, usernameIdentifier], + [emailIdentifier, { key: 'phoneVerified', value: '465789' }] + ) + ).toEqual([emailIdentifier, phoneIdentifier, usernameIdentifier]); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/interaction.ts b/packages/core/src/routes/interaction/utils/interaction.ts new file mode 100644 index 000000000..5237e6e41 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/interaction.ts @@ -0,0 +1,121 @@ +import type { Event, Profile } from '@logto/schemas'; +import type { Context } from 'koa'; +import type { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { anonymousInteractionResultGuard } from '../types/guard.js'; +import type { + Identifier, + AnonymousInteractionResult, + AccountVerifiedInteractionResult, +} from '../types/index.js'; + +const isProfileIdentifier = (identifier: Identifier, profile?: Profile) => { + if (identifier.key === 'accountId') { + return false; + } + + if (identifier.key === 'emailVerified') { + return profile?.email === identifier.value; + } + + if (identifier.key === 'phoneVerified') { + return profile?.phone === identifier.value; + } + + return profile?.connectorId === identifier.connectorId; +}; + +// Unique identifier type is required +export const mergeIdentifiers = (newIdentifiers: Identifier[], oldIdentifiers?: Identifier[]) => { + if (!oldIdentifiers) { + return newIdentifiers; + } + + // Filter out identifiers with the same key in the oldIdentifiers and replaced with new ones + const leftOvers = oldIdentifiers.filter((oldIdentifier) => { + return !newIdentifiers.some((newIdentifier) => newIdentifier.key === oldIdentifier.key); + }); + + return [...leftOvers, ...newIdentifiers]; +}; + +/** + * Categorize the identifiers based on their different use cases + * @typedef {Object} result + * @property {Identifier[]} userAccountIdentifiers - identifiers to verify a specific user account e.g. for sign-in and reset-password + * @property {Identifier[]} profileIdentifiers - identifiers to verify a new anonymous profile e.g. new email, new phone or new social identity + * + * @param {Identifier[]} identifiers + * @param {Profile} profile + * @returns + */ +export const categorizeIdentifiers = ( + identifiers: Identifier[], + profile?: Profile +): { + userAccountIdentifiers: Identifier[]; + profileIdentifiers: Identifier[]; +} => { + const userAccountIdentifiers = new Set(); + const profileIdentifiers = new Set(); + + for (const identifier of identifiers) { + if (isProfileIdentifier(identifier, profile)) { + profileIdentifiers.add(identifier); + continue; + } + userAccountIdentifiers.add(identifier); + } + + return { + userAccountIdentifiers: [...userAccountIdentifiers], + profileIdentifiers: [...profileIdentifiers], + }; +}; + +export const isAccountVerifiedInteractionResult = ( + interaction: AnonymousInteractionResult +): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId); + +export const storeInteractionResult = async ( + interaction: Omit & { event?: Event }, + ctx: Context, + provider: Provider +) => { + // The "mergeWithLastSubmission" will only merge current request's interaction results, + // manually merge with previous interaction results + // refer to: https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106 + + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + + await provider.interactionResult( + ctx.req, + ctx.res, + { ...result, ...interaction }, + { mergeWithLastSubmission: true } + ); +}; + +export const getInteractionStorage = async (ctx: Context, provider: Provider) => { + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + const parseResult = anonymousInteractionResultGuard.safeParse(result); + + assertThat( + parseResult.success, + new RequestError({ code: 'session.verification_session_not_found' }) + ); + + return parseResult.data; +}; + +export const clearInteractionStorage = async (ctx: Context, provider: Provider) => { + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + + if (result) { + const { event, profile, identifier, ...rest } = result; + await provider.interactionResult(ctx.req, ctx.res, { ...rest }); + } +}; diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.test.ts b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts new file mode 100644 index 000000000..3c2f9c51d --- /dev/null +++ b/packages/core/src/routes/interaction/utils/passcode-validation.test.ts @@ -0,0 +1,58 @@ +import { PasscodeType, Event } from '@logto/schemas'; +import { mockEsmWithActual } from '@logto/shared/esm'; + +import type { SendPasscodePayload } from '../types/index.js'; + +const { jest } = import.meta; +const passcode = { + createPasscode: jest.fn(() => ({})), + sendPasscode: jest.fn().mockResolvedValue({ dbEntry: { id: 'foo' } }), +}; + +await mockEsmWithActual('#src/lib/passcode.js', () => passcode); + +const { sendPasscodeToIdentifier } = await import('./passcode-validation.js'); + +const sendPasscodeTestCase = [ + { + payload: { email: 'email', event: Event.SignIn }, + createPasscodeParams: [PasscodeType.SignIn, { email: 'email' }], + }, + { + payload: { email: 'email', event: Event.Register }, + createPasscodeParams: [PasscodeType.Register, { email: 'email' }], + }, + { + payload: { email: 'email', event: Event.ForgotPassword }, + createPasscodeParams: [PasscodeType.ForgotPassword, { email: 'email' }], + }, + { + payload: { phone: 'phone', event: Event.SignIn }, + createPasscodeParams: [PasscodeType.SignIn, { phone: 'phone' }], + }, + { + payload: { phone: 'phone', event: Event.Register }, + createPasscodeParams: [PasscodeType.Register, { phone: 'phone' }], + }, + { + payload: { phone: 'phone', event: Event.ForgotPassword }, + createPasscodeParams: [PasscodeType.ForgotPassword, { phone: 'phone' }], + }, +]; + +describe('passcode-validation utils', () => { + const log = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each(sendPasscodeTestCase)( + 'send passcode successfully', + async ({ payload, createPasscodeParams }) => { + await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log); + expect(passcode.createPasscode).toBeCalledWith('jti', ...createPasscodeParams); + expect(passcode.sendPasscode).toBeCalled(); + } + ); +}); diff --git a/packages/core/src/routes/interaction/utils/passcode-validation.ts b/packages/core/src/routes/interaction/utils/passcode-validation.ts new file mode 100644 index 000000000..66adc62f6 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/passcode-validation.ts @@ -0,0 +1,62 @@ +import type { Event } from '@logto/schemas'; +import { PasscodeType } from '@logto/schemas'; + +import { createPasscode, sendPasscode, verifyPasscode } from '#src/lib/passcode.js'; +import type { LogContext } from '#src/middleware/koa-log.js'; +import { getPasswordlessRelatedLogType } from '#src/routes/session/utils.js'; + +import type { SendPasscodePayload, PasscodeIdentifierPayload } from '../types/index.js'; + +/** + * Refactor Needed: + * This is a work around to map the latest interaction event type to old PasscodeType + * */ +const eventToPasscodeTypeMap: Record = { + SignIn: PasscodeType.SignIn, + Register: PasscodeType.Register, + ForgotPassword: PasscodeType.ForgotPassword, +}; + +const getPasscodeTypeByEvent = (event: Event): PasscodeType => eventToPasscodeTypeMap[event]; + +export const sendPasscodeToIdentifier = async ( + payload: SendPasscodePayload, + jti: string, + log: LogContext['log'] +) => { + const { event, ...identifier } = payload; + const passcodeType = getPasscodeTypeByEvent(event); + + const logType = getPasswordlessRelatedLogType( + passcodeType, + 'email' in identifier ? 'email' : 'sms', + 'send' + ); + + log(logType, identifier); + + const passcode = await createPasscode(jti, passcodeType, identifier); + + const { dbEntry } = await sendPasscode(passcode); + + log(logType, { connectorId: dbEntry.id }); +}; + +export const verifyIdentifierByPasscode = async ( + payload: PasscodeIdentifierPayload & { event: Event }, + jti: string, + log: LogContext['log'] +) => { + const { event, passcode, ...identifier } = payload; + const passcodeType = getPasscodeTypeByEvent(event); + + const logType = getPasswordlessRelatedLogType( + passcodeType, + 'email' in identifier ? 'email' : 'sms', + 'verify' + ); + + log(logType, identifier); + + await verifyPasscode(jti, passcodeType, passcode, identifier); +}; diff --git a/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts b/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts new file mode 100644 index 000000000..a3917707a --- /dev/null +++ b/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts @@ -0,0 +1,257 @@ +import type { SignInExperience } from '@logto/schemas'; +import { SignInIdentifier, SignInMode, Event } from '@logto/schemas'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; + +import { signInModeValidation, identifierValidation } from './sign-in-experience-validation.js'; + +describe('signInModeValidation', () => { + it(Event.Register, () => { + expect(() => { + signInModeValidation(Event.Register, { signInMode: SignInMode.SignIn } as SignInExperience); + }).toThrow(); + expect(() => { + signInModeValidation(Event.Register, { signInMode: SignInMode.Register } as SignInExperience); + }).not.toThrow(); + expect(() => { + signInModeValidation(Event.Register, { + signInMode: SignInMode.SignInAndRegister, + } as SignInExperience); + }).not.toThrow(); + }); + + it('SignIn', () => { + expect(() => { + signInModeValidation(Event.SignIn, { signInMode: SignInMode.SignIn } as SignInExperience); + }).not.toThrow(); + expect(() => { + signInModeValidation(Event.SignIn, { signInMode: SignInMode.Register } as SignInExperience); + }).toThrow(); + expect(() => { + signInModeValidation(Event.SignIn, { + signInMode: SignInMode.SignInAndRegister, + } as SignInExperience); + }).not.toThrow(); + }); + + it(Event.ForgotPassword, () => { + expect(() => { + signInModeValidation(Event.ForgotPassword, { + signInMode: SignInMode.SignIn, + } as SignInExperience); + }).not.toThrow(); + expect(() => { + signInModeValidation(Event.ForgotPassword, { + signInMode: SignInMode.Register, + } as SignInExperience); + }).not.toThrow(); + expect(() => { + signInModeValidation(Event.ForgotPassword, { + signInMode: SignInMode.SignInAndRegister, + } as SignInExperience); + }).not.toThrow(); + }); +}); + +describe('identifier validation', () => { + it('username-password', () => { + const identifier = { username: 'username', password: 'password' }; + + expect(() => { + identifierValidation(identifier, mockSignInExperience); + }).not.toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.filter( + ({ identifier }) => identifier !== SignInIdentifier.Username + ), + }, + }); + }).toThrow(); + }); + + it('email password', () => { + const identifier = { email: 'email', password: 'password' }; + + expect(() => { + identifierValidation(identifier, mockSignInExperience); + }).not.toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.filter( + ({ identifier }) => identifier !== SignInIdentifier.Email + ), + }, + }); + }).toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: false, + verificationCode: true, + isPasswordPrimary: true, + }, + ], + }, + }); + }).toThrow(); + }); + + it('email passcode', () => { + const identifier = { email: 'email', passcode: 'passcode' }; + + expect(() => { + identifierValidation(identifier, mockSignInExperience); + }).not.toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.filter( + ({ identifier }) => identifier !== SignInIdentifier.Email + ), + }, + }); + }).toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + }).toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signUp: { + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + }).not.toThrow(); + }); + + it('phone password', () => { + const identifier = { phone: '123', password: 'password' }; + + expect(() => { + identifierValidation(identifier, mockSignInExperience); + }).not.toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.filter( + ({ identifier }) => identifier !== SignInIdentifier.Sms + ), + }, + }); + }).toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Sms, + password: false, + verificationCode: true, + isPasswordPrimary: true, + }, + ], + }, + }); + }).toThrow(); + }); + + it('phone passcode', () => { + const identifier = { phone: '123456', passcode: 'passcode' }; + + expect(() => { + identifierValidation(identifier, mockSignInExperience); + }).not.toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.filter( + ({ identifier }) => identifier !== SignInIdentifier.Sms + ), + }, + }); + }).toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Sms, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + }).toThrow(); + + expect(() => { + identifierValidation(identifier, { + ...mockSignInExperience, + signUp: { + identifiers: [SignInIdentifier.Sms], + password: false, + verify: true, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Sms, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + }); + }).not.toThrow(); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts b/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts new file mode 100644 index 000000000..15849d618 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts @@ -0,0 +1,123 @@ +import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas'; +import { SignInMode, SignInIdentifier, Event } from '@logto/schemas'; + +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; + +const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 }); + +const forbiddenIdentifierError = new RequestError({ + code: 'user.sign_in_method_not_enabled', + status: 422, +}); + +export const signInModeValidation = (event: Event, { signInMode }: SignInExperience) => { + if (event === Event.SignIn) { + assertThat(signInMode !== SignInMode.Register, forbiddenEventError); + } + + if (event === Event.Register) { + assertThat(signInMode !== SignInMode.SignIn, forbiddenEventError); + } +}; + +export const identifierValidation = ( + identifier: IdentifierPayload, + signInExperience: SignInExperience +) => { + const { signIn, signUp } = signInExperience; + + // Username Password Identifier + if ('username' in identifier) { + assertThat( + signIn.methods.some( + ({ identifier: method, password }) => method === SignInIdentifier.Username && password + ), + forbiddenIdentifierError + ); + + return; + } + + // Email Identifier + if ('email' in identifier) { + assertThat( + // eslint-disable-next-line complexity + signIn.methods.some(({ identifier: method, password, verificationCode }) => { + if (method !== SignInIdentifier.Email) { + return false; + } + + // Email Password Verification + if ('password' in identifier && !password) { + return false; + } + + // Email Passcode Verification: SignIn verificationCode enabled or SignUp Email verify enabled + if ( + 'passcode' in identifier && + !verificationCode && + !signUp.identifiers.includes(SignInIdentifier.Email) && + !signUp.verify + ) { + return false; + } + + return true; + }), + forbiddenIdentifierError + ); + + return; + } + + // Phone Identifier + if ('phone' in identifier) { + assertThat( + // eslint-disable-next-line complexity + signIn.methods.some(({ identifier: method, password, verificationCode }) => { + if (method !== SignInIdentifier.Sms) { + return false; + } + + // Phone Password Verification + if ('password' in identifier && !password) { + return false; + } + + // Phone Passcode Verification: SignIn verificationCode enabled or SignUp Email verify enabled + if ( + 'passcode' in identifier && + !verificationCode && + !signUp.identifiers.includes(SignInIdentifier.Sms) && + !signUp.verify + ) { + return false; + } + + return true; + }), + forbiddenIdentifierError + ); + } + + // Social Identifier TODO: @darcy, @sijie +}; + +export const profileValidation = (profile: Profile, { signUp }: SignInExperience) => { + if (profile.phone) { + assertThat(signUp.identifiers.includes(SignInIdentifier.Sms), forbiddenIdentifierError); + } + + if (profile.email) { + assertThat(signUp.identifiers.includes(SignInIdentifier.Email), forbiddenIdentifierError); + } + + if (profile.username) { + assertThat(signUp.identifiers.includes(SignInIdentifier.Username), forbiddenIdentifierError); + } + + if (profile.password) { + assertThat(signUp.password, forbiddenIdentifierError); + } +}; diff --git a/packages/core/src/routes/interaction/utils/social-verification.test.ts b/packages/core/src/routes/interaction/utils/social-verification.test.ts new file mode 100644 index 000000000..42a2ce18d --- /dev/null +++ b/packages/core/src/routes/interaction/utils/social-verification.test.ts @@ -0,0 +1,32 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { mockEsm } from '@logto/shared/esm'; + +const { jest } = import.meta; + +const { getUserInfoByAuthCode } = mockEsm('#src/lib/social.js', () => ({ + getUserInfoByAuthCode: jest.fn().mockResolvedValue({ id: 'foo' }), +})); + +mockEsm('#src/connectors.js', () => ({ + getLogtoConnectorById: jest.fn().mockResolvedValue({ + metadata: { + id: 'social', + }, + type: ConnectorType.Social, + getAuthorizationUri: jest.fn(async () => ''), + }), +})); + +const { verifySocialIdentity } = await import('./social-verification.js'); +const log = jest.fn(); + +describe('social-verification', () => { + it('verifySocialIdentity', async () => { + const connectorId = 'connector'; + const connectorData = { authCode: 'code' }; + const userInfo = await verifySocialIdentity({ connectorId, connectorData }, log); + + expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData); + expect(userInfo).toEqual({ id: 'foo' }); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts new file mode 100644 index 000000000..787b69c60 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -0,0 +1,35 @@ +import type { SocialConnectorPayload, LogType } from '@logto/schemas'; +import { ConnectorType } from '@logto/schemas'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import type { SocialUserInfo } from '#src/connectors/types.js'; +import { getUserInfoByAuthCode } from '#src/lib/social.js'; +import type { LogContext } from '#src/middleware/koa-log.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { SocialAuthorizationUrlPayload } from '../types/index.js'; + +export const createSocialAuthorizationUrl = async (payload: SocialAuthorizationUrlPayload) => { + const { connectorId, state, redirectUri } = payload; + assertThat(state && redirectUri, 'session.insufficient_info'); + + const connector = await getLogtoConnectorById(connectorId); + + assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type'); + + return connector.getAuthorizationUri({ state, redirectUri }); +}; + +export const verifySocialIdentity = async ( + { connectorId, connectorData }: SocialConnectorPayload, + log: LogContext['log'] +): Promise => { + const logType: LogType = 'SignInSocial'; + log(logType, { connectorId, connectorData }); + + const userInfo = await getUserInfoByAuthCode(connectorId, connectorData); + + log(logType, userInfo); + + return userInfo; +}; diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts new file mode 100644 index 000000000..5c25c59f3 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts @@ -0,0 +1,334 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { AnonymousInteractionResult, VerifiedPhoneIdentifier } from '../types/index.js'; + +const { jest } = import.meta; + +const { verifyUserPassword } = mockEsm('#src/lib/user.js', () => ({ + verifyUserPassword: jest.fn(), +})); + +const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn()); + +await mockEsmWithActual('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +const { verifyIdentifierByPasscode } = mockEsm('../utils/passcode-validation.js', () => ({ + verifyIdentifierByPasscode: jest.fn(), +})); + +const { verifySocialIdentity } = mockEsm('../utils/social-verification.js', () => ({ + verifySocialIdentity: jest.fn().mockResolvedValue({ id: 'foo' }), +})); + +const identifierPayloadVerification = await pickDefault( + import('./identifier-payload-verification.js') +); + +const log = jest.fn(); + +describe('identifier verification', () => { + const baseCtx = { ...createContextWithRouteParameters(), log }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('username password user not found', async () => { + findUserByIdentifier.mockResolvedValueOnce(null); + + const identifier = { + username: 'username', + password: 'password', + }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toThrow(); + expect(findUserByIdentifier).toBeCalledWith({ username: 'username' }); + expect(verifyUserPassword).toBeCalledWith(null, 'password'); + }); + + it('username password user is suspended', async () => { + findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' }); + verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: true }); + const identifier = { + username: 'username', + password: 'password', + }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toMatchError( + new RequestError({ code: 'user.suspended', status: 401 }) + ); + + expect(findUserByIdentifier).toBeCalledWith({ username: 'username' }); + expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password'); + }); + + it('email password', async () => { + findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' }); + verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: false }); + + const identifier = { + email: 'email', + password: 'password', + }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, createMockProvider()); + expect(findUserByIdentifier).toBeCalledWith({ email: 'email' }); + expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password'); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'accountId', value: 'foo' }], + }); + }); + + it('phone password', async () => { + findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' }); + verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: false }); + + const identifier = { + phone: 'phone', + password: 'password', + }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, createMockProvider()); + expect(findUserByIdentifier).toBeCalledWith({ phone: 'phone' }); + expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password'); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'accountId', value: 'foo' }], + }); + }); + + it('email passcode', async () => { + const identifier = { email: 'email', passcode: 'passcode' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, createMockProvider()); + expect(verifyIdentifierByPasscode).toBeCalledWith( + { ...identifier, event: Event.SignIn }, + 'jti', + log + ); + + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'emailVerified', value: identifier.email }], + }); + }); + + it('phone passcode', async () => { + const identifier = { phone: 'phone', passcode: 'passcode' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, createMockProvider()); + expect(verifyIdentifierByPasscode).toBeCalledWith( + { ...identifier, event: Event.SignIn }, + 'jti', + log + ); + + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'phoneVerified', value: identifier.phone }], + }); + }); + + it('social', async () => { + const identifier = { connectorId: 'logto', connectorData: {} }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, createMockProvider()); + + expect(verifySocialIdentity).toBeCalledWith(identifier, log); + expect(findUserByIdentifier).not.toBeCalled(); + + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [ + { key: 'social', connectorId: identifier.connectorId, userInfo: { id: 'foo' } }, + ], + }); + }); + + it('verified social email', async () => { + const interactionRecord: AnonymousInteractionResult = { + event: Event.SignIn, + identifiers: [ + { + key: 'social', + connectorId: 'logto', + userInfo: { + id: 'foo', + email: 'email@logto.io', + }, + }, + ], + }; + + const identifierPayload = { connectorId: 'logto', identityType: 'email' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: identifierPayload, + }), + }; + + const result = await identifierPayloadVerification( + ctx, + createMockProvider(), + interactionRecord + ); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [ + { + key: 'social', + connectorId: 'logto', + userInfo: { + id: 'foo', + email: 'email@logto.io', + }, + }, + { + key: 'emailVerified', + value: 'email@logto.io', + }, + ], + }); + }); + + it('verified social email should throw if social session not found', async () => { + const identifierPayload = { connectorId: 'logto', identityType: 'email' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: identifierPayload, + }), + }; + + await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toMatchError( + new RequestError('session.connector_session_not_found') + ); + }); + + it('verified social email should throw if social identity not found', async () => { + const interactionRecord: AnonymousInteractionResult = { + event: Event.SignIn, + identifiers: [ + { + key: 'social', + connectorId: 'logto', + userInfo: { + id: 'foo', + }, + }, + ], + }; + + const identifierPayload = { connectorId: 'logto', identityType: 'email' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: identifierPayload, + }), + }; + + await expect( + identifierPayloadVerification(ctx, createMockProvider(), interactionRecord) + ).rejects.toMatchError(new RequestError('session.connector_session_not_found')); + }); + + it('should merge identifier if exist', async () => { + const identifier = { email: 'email', passcode: 'passcode' }; + const oldIdentifier: VerifiedPhoneIdentifier = { key: 'phoneVerified', value: '123456' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, createMockProvider(), { + event: Event.Register, + identifiers: [oldIdentifier], + }); + + expect(verifyIdentifierByPasscode).toBeCalledWith( + { ...identifier, event: Event.SignIn }, + 'jti', + log + ); + + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [oldIdentifier, { key: 'emailVerified', value: identifier.email }], + }); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts new file mode 100644 index 000000000..325f6aec7 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -0,0 +1,133 @@ +import type { Event, SocialConnectorPayload, SocialIdentityPayload } from '@logto/schemas'; +import type { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { verifyUserPassword } from '#src/lib/user.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { + PasswordIdentifierPayload, + PasscodeIdentifierPayload, + InteractionContext, + SocialIdentifier, + VerifiedEmailIdentifier, + VerifiedPhoneIdentifier, + AnonymousInteractionResult, + PayloadVerifiedInteractionResult, + Identifier, + AccountIdIdentifier, +} from '../types/index.js'; +import findUserByIdentifier from '../utils/find-user-by-identifier.js'; +import { isPasscodeIdentifier, isPasswordIdentifier, isSocialIdentifier } from '../utils/index.js'; +import { mergeIdentifiers, storeInteractionResult } from '../utils/interaction.js'; +import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js'; +import { verifySocialIdentity } from '../utils/social-verification.js'; + +const verifyPasswordIdentifier = async ( + identifier: PasswordIdentifierPayload +): Promise => { + // TODO: Log + const { password, ...identity } = identifier; + const user = await findUserByIdentifier(identity); + const verifiedUser = await verifyUserPassword(user, password); + + const { isSuspended, id } = verifiedUser; + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + return { key: 'accountId', value: id }; +}; + +const verifyPasscodeIdentifier = async ( + event: Event, + identifier: PasscodeIdentifierPayload, + ctx: InteractionContext, + provider: Provider +): Promise => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + + await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log); + + return 'email' in identifier + ? { key: 'emailVerified', value: identifier.email } + : { key: 'phoneVerified', value: identifier.phone }; +}; + +const verifySocialIdentifier = async ( + identifier: SocialConnectorPayload, + ctx: InteractionContext +): Promise => { + const userInfo = await verifySocialIdentity(identifier, ctx.log); + + return { key: 'social', connectorId: identifier.connectorId, userInfo }; +}; + +const verifySocialIdentityInInteractionRecord = async ( + { connectorId, identityType }: SocialIdentityPayload, + interactionRecord?: AnonymousInteractionResult +): Promise => { + // Sign-In with social verified email or phone requires a social identifier in the interaction result + const socialIdentifierRecord = interactionRecord?.identifiers?.find( + (entity): entity is SocialIdentifier => + entity.key === 'social' && entity.connectorId === connectorId + ); + + const verifiedSocialIdentity = socialIdentifierRecord?.userInfo[identityType]; + + assertThat(verifiedSocialIdentity, new RequestError('session.connector_session_not_found')); + + return { + key: identityType === 'email' ? 'emailVerified' : 'phoneVerified', + value: verifiedSocialIdentity, + }; +}; + +const verifyIdentifierPayload = async ( + ctx: InteractionContext, + provider: Provider, + interactionRecord?: AnonymousInteractionResult +): Promise => { + const { identifier, event } = ctx.interactionPayload; + + // No Identifier in payload + if (!identifier) { + return; + } + + if (isPasswordIdentifier(identifier)) { + return verifyPasswordIdentifier(identifier); + } + + if (isPasscodeIdentifier(identifier)) { + return verifyPasscodeIdentifier(event, identifier, ctx, provider); + } + + if (isSocialIdentifier(identifier)) { + return verifySocialIdentifier(identifier, ctx); + } + + // Sign-In with social verified email or phone + return verifySocialIdentityInInteractionRecord(identifier, interactionRecord); +}; + +export default async function identifierPayloadVerification( + ctx: InteractionContext, + provider: Provider, + interactionRecord?: AnonymousInteractionResult +): Promise { + const { event } = ctx.interactionPayload; + + const identifier = await verifyIdentifierPayload(ctx, provider, interactionRecord); + + const interaction: PayloadVerifiedInteractionResult = { + ...interactionRecord, + event, + identifiers: identifier + ? mergeIdentifiers([identifier], interactionRecord?.identifiers) + : interactionRecord?.identifiers, + }; + + await storeInteractionResult(interaction, ctx, provider); + + return interaction; +} diff --git a/packages/core/src/routes/interaction/verifications/identifier-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-verification.ts new file mode 100644 index 000000000..d3075e54b --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/identifier-verification.ts @@ -0,0 +1,24 @@ +import { Event } from '@logto/schemas'; +import type { Provider } from 'oidc-provider'; + +import type { + InteractionContext, + AnonymousInteractionResult, + IdentifierVerifiedInteractionResult, +} from '../types/index.js'; +import identifierPayloadVerification from './identifier-payload-verification.js'; +import userAccountVerification from './user-identity-verification.js'; + +export default async function verifyIdentifier( + ctx: InteractionContext, + provider: Provider, + interactionRecord?: AnonymousInteractionResult +): Promise { + const verifiedInteraction = await identifierPayloadVerification(ctx, provider, interactionRecord); + + if (verifiedInteraction.event === Event.Register) { + return verifiedInteraction; + } + + return userAccountVerification(verifiedInteraction, ctx, provider); +} diff --git a/packages/core/src/routes/interaction/verifications/index.ts b/packages/core/src/routes/interaction/verifications/index.ts new file mode 100644 index 000000000..d5af13cb2 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/index.ts @@ -0,0 +1,3 @@ +export { default as verifyProfile } from './profile-verification.js'; +export { default as validateMandatoryUserProfile } from './mandatory-user-profile-validation.js'; +export { default as verifyIdentifier } from './identifier-verification.js'; diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts new file mode 100644 index 000000000..4318fb820 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -0,0 +1,163 @@ +import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; + +const { jest } = import.meta; + +const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({ + findUserById: jest.fn(), +})); + +const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({ + isUserPasswordSet: jest.fn(), +})); + +const validateMandatoryUserProfile = await pickDefault( + import('./mandatory-user-profile-validation.js') +); + +describe('validateMandatoryUserProfile', () => { + const baseCtx = createContextWithRouteParameters(); + const interaction: IdentifierVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + }; + + it('username and password missing but required', async () => { + const ctx = { + ...baseCtx, + signInExperience: mockSignInExperience, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + new RequestError( + { code: 'user.missing_profile', status: 422 }, + { missingProfile: [MissingProfile.password, MissingProfile.username] } + ) + ); + + await expect( + validateMandatoryUserProfile(ctx, { + ...interaction, + profile: { + username: 'username', + password: 'password', + }, + }) + ).resolves.not.toThrow(); + }); + + it('user account has username and password', async () => { + findUserById.mockResolvedValueOnce({ + username: 'foo', + }); + isUserPasswordSet.mockResolvedValueOnce(true); + + const ctx = { + ...baseCtx, + signInExperience: mockSignInExperience, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); + }); + + it('email missing but required', async () => { + const ctx = { + ...baseCtx, + signInExperience: { + ...mockSignInExperience, + signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true }, + }, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + new RequestError( + { code: 'user.missing_profile', status: 422 }, + { missingProfile: [MissingProfile.email] } + ) + ); + }); + + it('user account has email', async () => { + findUserById.mockResolvedValueOnce({ + primaryEmail: 'email', + }); + + const ctx = { + ...baseCtx, + signInExperience: { + ...mockSignInExperience, + signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true }, + }, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); + }); + + it('phone missing but required', async () => { + const ctx = { + ...baseCtx, + signInExperience: { + ...mockSignInExperience, + signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true }, + }, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + new RequestError( + { code: 'user.missing_profile', status: 422 }, + { missingProfile: [MissingProfile.phone] } + ) + ); + }); + + it('user account has phone', async () => { + findUserById.mockResolvedValueOnce({ + primaryPhone: 'phone', + }); + + const ctx = { + ...baseCtx, + signInExperience: { + ...mockSignInExperience, + signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true }, + }, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); + }); + + it('email or Phone required', async () => { + const ctx = { + ...baseCtx, + signInExperience: { + ...mockSignInExperience, + signUp: { + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], + password: false, + verify: true, + }, + }, + }; + + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + new RequestError( + { code: 'user.missing_profile', status: 422 }, + { missingProfile: [MissingProfile.emailOrPhone] } + ) + ); + + await expect( + validateMandatoryUserProfile(ctx, { ...interaction, profile: { email: 'email' } }) + ).resolves.not.toThrow(); + + await expect( + validateMandatoryUserProfile(ctx, { ...interaction, profile: { phone: '123456' } }) + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts new file mode 100644 index 000000000..6b5b2caaf --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts @@ -0,0 +1,91 @@ +import type { Profile, SignInExperience, User } from '@logto/schemas'; +import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas'; +import type { Nullable } from '@silverhand/essentials'; +import type { Context } from 'koa'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { findUserById } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { WithSignInExperienceContext } from '../middleware/koa-session-sign-in-experience-guard.js'; +import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; +import { isUserPasswordSet } from '../utils/index.js'; + +// eslint-disable-next-line complexity +const getMissingProfileBySignUpIdentifiers = ({ + signUp, + user, + profile, +}: { + signUp: SignInExperience['signUp']; + user: Nullable; + profile?: Profile; +}) => { + const missingProfile = new Set(); + + if (signUp.password && !(user && isUserPasswordSet(user)) && !profile?.password) { + missingProfile.add(MissingProfile.password); + } + + const signUpIdentifiersSet = new Set(signUp.identifiers); + + // Username + if ( + signUpIdentifiersSet.has(SignInIdentifier.Username) && + !user?.username && + !profile?.username + ) { + missingProfile.add(MissingProfile.username); + + return missingProfile; + } + + // Email or phone + if ( + signUpIdentifiersSet.has(SignInIdentifier.Email) && + signUpIdentifiersSet.has(SignInIdentifier.Sms) + ) { + if (!user?.primaryPhone && !user?.primaryEmail && !profile?.phone && !profile?.email) { + missingProfile.add(MissingProfile.emailOrPhone); + } + + return missingProfile; + } + + // Email only + if (signUpIdentifiersSet.has(SignInIdentifier.Email) && !user?.primaryEmail && !profile?.email) { + missingProfile.add(MissingProfile.email); + + return missingProfile; + } + + // Phone only + if (signUpIdentifiersSet.has(SignInIdentifier.Sms) && !user?.primaryPhone && !profile?.phone) { + missingProfile.add(MissingProfile.phone); + + return missingProfile; + } + + return missingProfile; +}; + +export default async function validateMandatoryUserProfile( + ctx: WithSignInExperienceContext, + interaction: IdentifierVerifiedInteractionResult +) { + const { + signInExperience: { signUp }, + } = ctx; + const { event, accountId, profile } = interaction; + + const user = event === Event.Register ? null : await findUserById(accountId); + const missingProfileSet = getMissingProfileBySignUpIdentifiers({ signUp, user, profile }); + + assertThat( + missingProfileSet.size === 0, + new RequestError( + { code: 'user.missing_profile', status: 422 }, + { missingProfile: Array.from(missingProfileSet) } + ) + ); +} diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts new file mode 100644 index 000000000..8647d8aff --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts @@ -0,0 +1,91 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { InteractionContext } from '../types/index.js'; + +const { jest } = import.meta; + +const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({ + findUserById: jest.fn().mockResolvedValue({ id: 'foo', passwordEncrypted: 'passwordHash' }), +})); + +const { argon2Verify } = mockEsm('hash-wasm', () => ({ + argon2Verify: jest.fn(), +})); + +const verifyProfile = await pickDefault(import('./profile-verification.js')); + +describe('forgot password interaction profile verification', () => { + const provider = createMockProvider(); + const baseCtx = createContextWithRouteParameters(); + + const interaction = { + event: Event.ForgotPassword, + accountId: 'foo', + }; + + it('missing profile', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.ForgotPassword, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.new_password_required_in_profile', + status: 422, + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('same password', async () => { + argon2Verify.mockResolvedValueOnce(true); + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.ForgotPassword, + profile: { + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.same_password', + status: 422, + }) + ); + expect(findUserById).toBeCalledWith(interaction.accountId); + expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('proper set password', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.ForgotPassword, + profile: { + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow(); + expect(findUserById).toBeCalledWith(interaction.accountId); + expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' }); + expect(storeInteractionResult).toBeCalled(); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts new file mode 100644 index 000000000..992b361cb --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts @@ -0,0 +1,135 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { + Identifier, + IdentifierVerifiedInteractionResult, + InteractionContext, +} from '../types/index.js'; + +const { jest } = import.meta; + +const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({ + findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), +})); + +const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +mockEsm('../utils/index.js', () => ({ + isUserPasswordSet: jest.fn().mockResolvedValueOnce(true), +})); + +const verifyProfile = await pickDefault(import('./profile-verification.js')); + +describe('Should throw when providing existing identifiers in profile', () => { + const provider = createMockProvider(); + const baseCtx = createContextWithRouteParameters(); + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: 'phone' }, + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, + ]; + const interaction: IdentifierVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + identifiers, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('username exists', async () => { + findUserById.mockResolvedValueOnce({ id: 'foo', username: 'foo' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + username: 'username', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.username_exists_in_profile', + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('email exists', async () => { + findUserById.mockResolvedValueOnce({ id: 'foo', primaryEmail: 'email' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.email_exists_in_profile', + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('phone exists', async () => { + findUserById.mockResolvedValueOnce({ id: 'foo', primaryPhone: 'phone' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.phone_exists_in_profile', + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('password exists', async () => { + findUserById.mockResolvedValueOnce({ id: 'foo' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.password_exists_in_profile', + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts new file mode 100644 index 000000000..295af32bd --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts @@ -0,0 +1,222 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { + Identifier, + InteractionContext, + IdentifierVerifiedInteractionResult, +} from '../types/index.js'; + +const { jest } = import.meta; + +const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = + await mockEsmWithActual('#src/queries/user.js', () => ({ + hasUser: jest.fn().mockResolvedValue(false), + findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), + })); + +mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest.fn().mockResolvedValue({ + metadata: { target: 'logto' }, + }), +})); + +const verifyProfile = await pickDefault(import('./profile-verification.js')); + +const baseCtx = createContextWithRouteParameters(); +const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email@logto.io' }, + { key: 'phoneVerified', value: '123456' }, + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, +]; +const provider = createMockProvider(); + +const interaction: IdentifierVerifiedInteractionResult = { + event: Event.Register, + identifiers, +}; + +describe('register payload guard', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('username only should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toThrow(); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('password only should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toThrow(); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('username password is valid', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + password: 'password', + }, + }, + }; + + const result = await verifyProfile(ctx, provider, interaction); + expect(result).toEqual({ ...interaction, profile: ctx.interactionPayload.profile }); + }); + + it('username with a given email is valid', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + email: 'email@logto.io', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow(); + }); + + it('password with a given email is valid', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + password: 'password', + email: 'email@logto.io', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow(); + }); +}); + +describe('profile registered validation', () => { + it('username is registered', async () => { + hasUser.mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.username_already_in_use', + status: 422, + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('email is registered', async () => { + hasUserWithEmail.mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + email: 'email@logto.io', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.email_already_in_use', + status: 422, + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('phone is registered', async () => { + hasUserWithPhone.mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + phone: '123456', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.phone_already_in_use', + status: 422, + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('connector identity exist', async () => { + hasUserWithIdentity.mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + connectorId: 'connectorId', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.identity_already_in_use', + status: 422, + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts new file mode 100644 index 000000000..571a94aad --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts @@ -0,0 +1,240 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { Identifier, InteractionContext } from '../types/index.js'; + +const { jest } = import.meta; + +const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +await mockEsmWithActual('#src/queries/user.js', () => ({ + findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), +})); + +mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest.fn().mockResolvedValue({ + metadata: { target: 'logto' }, + }), +})); + +const verifyProfile = await pickDefault(import('./profile-verification.js')); + +describe('profile protected identifier verification', () => { + const baseCtx = createContextWithRouteParameters(); + const interaction = { event: Event.SignIn, accountId: 'foo' }; + const provider = createMockProvider(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('email, phone and social identifier must be verified', () => { + it('email without a verified identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('email with unmatched identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'phone' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('email with proper identifier should not throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'email' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).resolves.not.toThrow(); + expect(storeInteractionResult).toBeCalledWith( + { + ...interaction, + identifiers, + profile: ctx.interactionPayload.profile, + }, + ctx, + provider + ); + }); + + it('phone without a verified identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('phone with unmatched identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'email' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('phone with proper identifier should not throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'phone' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).resolves.not.toThrow(); + expect(storeInteractionResult).toBeCalledWith( + { + ...interaction, + identifiers, + profile: ctx.interactionPayload.profile, + }, + ctx, + provider + ); + }); + + it('connectorId without a verified identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'connectorId', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'foo@logto.io' }]; + + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( + new RequestError({ code: 'session.connector_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('connectorId with unmatched identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'logto', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, + ]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( + new RequestError({ code: 'session.connector_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('connectorId with proper identifier should not throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'logto', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'social', connectorId: 'logto', userInfo: { id: 'foo' } }, + ]; + + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).resolves.not.toThrow(); + expect(storeInteractionResult).toBeCalledWith( + { + ...interaction, + identifiers, + profile: ctx.interactionPayload.profile, + }, + ctx, + provider + ); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.ts b/packages/core/src/routes/interaction/verifications/profile-verification.ts new file mode 100644 index 000000000..a986a6945 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification.ts @@ -0,0 +1,234 @@ +import type { Profile, User } from '@logto/schemas'; +import { Event } from '@logto/schemas'; +import { argon2Verify } from 'hash-wasm'; +import type { Provider } from 'oidc-provider'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { + findUserById, + hasUser, + hasUserWithEmail, + hasUserWithPhone, + hasUserWithIdentity, +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { registerProfileSafeGuard, forgotPasswordProfileGuard } from '../types/guard.js'; +import type { + InteractionContext, + Identifier, + SocialIdentifier, + IdentifierVerifiedInteractionResult, + VerifiedInteractionResult, + VerifiedRegisterInteractionResult, + RegisterSafeProfile, + VerifiedSignInInteractionResult, + VerifiedForgotPasswordInteractionResult, +} from '../types/index.js'; +import { isUserPasswordSet } from '../utils/index.js'; +import { storeInteractionResult } from '../utils/interaction.js'; + +const verifyProfileIdentifiers = ( + { email, phone, connectorId }: Profile, + identifiers: Identifier[] = [] +) => { + if (email) { + assertThat( + identifiers.some( + (identifier) => identifier.key === 'emailVerified' && identifier.value === email + ), + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + } + + if (phone) { + assertThat( + identifiers.some( + (identifier) => identifier.key === 'phoneVerified' && identifier.value === phone + ), + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + } + + if (connectorId) { + assertThat( + identifiers.some( + (identifier) => identifier.key === 'social' && identifier.connectorId === connectorId + ), + new RequestError({ + code: 'session.connector_session_not_found', + status: 404, + }) + ); + } +}; + +const verifyProfileNotRegisteredByOtherUserAccount = async ( + { username, email, phone, connectorId }: Profile, + identifiers: Identifier[] = [] +) => { + if (username) { + assertThat( + !(await hasUser(username)), + new RequestError({ + code: 'user.username_already_in_use', + status: 422, + }) + ); + } + + if (email) { + assertThat( + !(await hasUserWithEmail(email)), + new RequestError({ + code: 'user.email_already_in_use', + status: 422, + }) + ); + } + + if (phone) { + assertThat( + !(await hasUserWithPhone(phone)), + new RequestError({ + code: 'user.phone_already_in_use', + status: 422, + }) + ); + } + + if (connectorId) { + const { + metadata: { target }, + } = await getLogtoConnectorById(connectorId); + + const socialIdentifier = identifiers.find( + (identifier): identifier is SocialIdentifier => identifier.key === 'social' + ); + + // Social identifier session should be verified by verifyProfileIdentifiers + if (!socialIdentifier) { + return; + } + + assertThat( + !(await hasUserWithIdentity(target, socialIdentifier.userInfo.id)), + new RequestError({ + code: 'user.identity_already_in_use', + status: 422, + }) + ); + } +}; + +const verifyProfileNotExistInCurrentUserAccount = async ( + { username, email, phone, password }: Profile, + user: User +) => { + if (username) { + assertThat( + !user.username, + new RequestError({ + code: 'user.username_exists_in_profile', + }) + ); + } + + if (email) { + assertThat( + !user.primaryEmail, + new RequestError({ + code: 'user.email_exists_in_profile', + }) + ); + } + + if (phone) { + assertThat( + !user.primaryPhone, + new RequestError({ + code: 'user.phone_exists_in_profile', + }) + ); + } + + if (password) { + assertThat( + !isUserPasswordSet(user), + new RequestError({ + code: 'user.password_exists_in_profile', + }) + ); + } +}; + +const isValidRegisterProfile = (profile: Profile): profile is RegisterSafeProfile => + registerProfileSafeGuard.safeParse(profile).success; + +export default async function verifyProfile( + ctx: InteractionContext, + provider: Provider, + interaction: IdentifierVerifiedInteractionResult +): Promise { + const profile = { ...interaction.profile, ...ctx.interactionPayload.profile }; + + const { event, identifiers, accountId } = interaction; + + if (event === Event.Register) { + // Verify the profile includes sufficient identifiers to register a new account + assertThat(isValidRegisterProfile(profile), new RequestError({ code: 'guard.invalid_input' })); + + verifyProfileIdentifiers(profile, identifiers); + await verifyProfileNotRegisteredByOtherUserAccount(profile, identifiers); + + const interactionWithProfile: VerifiedRegisterInteractionResult = { ...interaction, profile }; + await storeInteractionResult(interactionWithProfile, ctx, provider); + + return interactionWithProfile; + } + + if (event === Event.SignIn) { + verifyProfileIdentifiers(profile, identifiers); + // Find existing account + const user = await findUserById(accountId); + await verifyProfileNotExistInCurrentUserAccount(profile, user); + await verifyProfileNotRegisteredByOtherUserAccount(profile, identifiers); + + const interactionWithProfile: VerifiedSignInInteractionResult = { ...interaction, profile }; + await storeInteractionResult(interactionWithProfile, ctx, provider); + + return interactionWithProfile; + } + + // Forgot Password + const passwordProfileResult = forgotPasswordProfileGuard.safeParse(profile); + assertThat( + passwordProfileResult.success, + new RequestError({ code: 'user.new_password_required_in_profile', status: 422 }) + ); + + const passwordProfile = passwordProfileResult.data; + + const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(accountId); + + assertThat( + !oldPasswordEncrypted || + !(await argon2Verify({ password: passwordProfile.password, hash: oldPasswordEncrypted })), + new RequestError({ code: 'user.same_password', status: 422 }) + ); + + const interactionWithProfile: VerifiedForgotPasswordInteractionResult = { + ...interaction, + profile: passwordProfile, + }; + await storeInteractionResult(interactionWithProfile, ctx, provider); + + return interactionWithProfile; +} diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts new file mode 100644 index 000000000..bc3c305a6 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts @@ -0,0 +1,283 @@ +import { Event } from '@logto/schemas'; +import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { InteractionContext, PayloadVerifiedInteractionResult } from '../types/index.js'; + +const { jest } = import.meta; + +const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn()); + +mockEsm('#src/lib/social.js', () => ({ + findSocialRelatedUser: jest.fn().mockResolvedValue(null), +})); + +const { storeInteractionResult } = await mockEsmWithActual('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +const userAccountVerification = await pickDefault(import('./user-identity-verification.js')); + +describe('userAccountVerification', () => { + const findUserByIdentifierMock = findUserByIdentifier; + + const ctx: InteractionContext = { + ...createContextWithRouteParameters(), + interactionPayload: { + event: Event.SignIn, + }, + }; + const provider = createMockProvider(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('empty identifiers with accountId', async () => { + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + }; + + const result = await userAccountVerification(interaction, ctx, provider); + + expect(storeInteractionResult).not.toBeCalled(); + expect(result).toEqual(result); + }); + + it('empty identifiers withOut accountId should throw', async () => { + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify accountId identifier', async () => { + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'accountId', value: 'foo' }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify emailVerified identifier', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'emailVerified', value: 'email' }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify phoneVerified identifier', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'phoneVerified', value: '123456' }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ phone: '123456' }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify social identifier', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ + connectorId: 'connectorId', + userInfo: { id: 'foo' }, + }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify social identifier user identity not exist', async () => { + findUserByIdentifierMock.mockResolvedValueOnce(null); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError( + { + code: 'user.identity_not_exist', + status: 422, + }, + null + ) + ); + + expect(findUserByIdentifierMock).toBeCalledWith({ + connectorId: 'connectorId', + userInfo: { id: 'foo' }, + }); + + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify accountId and emailVerified identifier with same user', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + ], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify accountId and emailVerified identifier with email user not exist', async () => { + findUserByIdentifierMock.mockResolvedValueOnce(null); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + ], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' }) + ); + + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify phoneVerified and emailVerified identifier with email user suspend', async () => { + findUserByIdentifierMock + .mockResolvedValueOnce({ id: 'foo' }) + .mockResolvedValueOnce({ id: 'foo2', isSuspended: true }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: '123456' }, + ], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError({ code: 'user.suspended', status: 401 }) + ); + + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' }); + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify phoneVerified and emailVerified identifier returns inconsistent id', async () => { + findUserByIdentifierMock + .mockResolvedValueOnce({ id: 'foo' }) + .mockResolvedValueOnce({ id: 'foo2' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: '123456' }, + ], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError('session.verification_failed') + ); + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' }); + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify emailVerified identifier returns inconsistent id with existing accountId', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo2', + identifiers: [{ key: 'emailVerified', value: 'email' }], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError('session.verification_failed') + ); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('profile use identifier should remain', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: '123456' }, + ], + profile: { + phone: '123456', + }, + }; + + const ctxWithSocialProfile: InteractionContext = { + ...ctx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'connectorId', + }, + }, + }; + + const result = await userAccountVerification(interaction, ctxWithSocialProfile, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).toBeCalledWith(result, ctxWithSocialProfile, provider); + expect(result).toEqual({ + event: Event.SignIn, + accountId: 'foo', + identifiers: [ + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, + { key: 'phoneVerified', value: '123456' }, + ], + profile: { + phone: '123456', + }, + }); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts new file mode 100644 index 000000000..1c8a319b0 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts @@ -0,0 +1,133 @@ +import { deduplicate } from '@silverhand/essentials'; +import type { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { findSocialRelatedUser } from '#src/lib/social.js'; +import assertThat from '#src/utils/assert-that.js'; +import { maskUserInfo } from '#src/utils/format.js'; + +import type { + SocialIdentifier, + VerifiedEmailIdentifier, + VerifiedPhoneIdentifier, + PreAccountVerifiedInteractionResult, + AccountVerifiedInteractionResult, + Identifier, + InteractionContext, +} from '../types/index.js'; +import findUserByIdentifier from '../utils/find-user-by-identifier.js'; +import { + storeInteractionResult, + isAccountVerifiedInteractionResult, + categorizeIdentifiers, +} from '../utils/interaction.js'; + +const identifyUserByVerifiedEmailOrPhone = async ( + identifier: VerifiedEmailIdentifier | VerifiedPhoneIdentifier +) => { + const user = await findUserByIdentifier( + identifier.key === 'emailVerified' ? { email: identifier.value } : { phone: identifier.value } + ); + + assertThat( + user, + new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: identifier.value }) + ); + + const { id, isSuspended } = user; + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + return id; +}; + +const identifyUserBySocialIdentifier = async (identifier: SocialIdentifier) => { + const { connectorId, userInfo } = identifier; + + const user = await findUserByIdentifier({ connectorId, userInfo }); + + if (!user) { + const relatedInfo = await findSocialRelatedUser(userInfo); + + throw new RequestError( + { + code: 'user.identity_not_exist', + status: 422, + }, + relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) } + ); + } + + const { id, isSuspended } = user; + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + return id; +}; + +const identifyUser = async (identifier: Identifier) => { + if (identifier.key === 'social') { + return identifyUserBySocialIdentifier(identifier); + } + + if (identifier.key === 'accountId') { + return identifier.value; + } + + return identifyUserByVerifiedEmailOrPhone(identifier); +}; + +export default async function userAccountVerification( + interaction: PreAccountVerifiedInteractionResult, + ctx: InteractionContext, + provider: Provider +): Promise { + const { identifiers = [], accountId, profile } = interaction; + + const { userAccountIdentifiers, profileIdentifiers } = categorizeIdentifiers( + identifiers, + // Need to merge the profile in payload + { ...profile, ...ctx.interactionPayload.profile } + ); + + // Return the interaction directly if it is accountVerified and has no unverified userAccountIdentifiers + // e.g. profile fulfillment request with account already verified in the interaction result + if (isAccountVerifiedInteractionResult(interaction) && userAccountIdentifiers.length === 0) { + return interaction; + } + + // _userAccountIdentifiers is required to identify a user account + assertThat( + userAccountIdentifiers.length > 0, + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + + // Verify userAccountIdentifiers + const accountIds = await Promise.all( + userAccountIdentifiers.map(async (identifier) => identifyUser(identifier)) + ); + const deduplicateAccountIds = deduplicate(accountIds); + + // Inconsistent account identifiers check + assertThat(deduplicateAccountIds.length === 1, new RequestError('session.verification_failed')); + + // Valid accountId verification. Should also equal to the accountId in record if exist. Else throw + assertThat( + deduplicateAccountIds[0] && (!accountId || accountId === deduplicateAccountIds[0]), + new RequestError('session.verification_failed') + ); + + // Assign the verification result and store the profile identifiers left + const verifiedInteraction: AccountVerifiedInteractionResult = { + ...interaction, + identifiers: profileIdentifiers, + accountId: deduplicateAccountIds[0], + }; + + await storeInteractionResult(verifiedInteraction, ctx, provider); + + return verifiedInteraction; +} diff --git a/packages/core/src/routes/log.test.ts b/packages/core/src/routes/log.test.ts index 25c6e8d3d..ae3d50504 100644 --- a/packages/core/src/routes/log.test.ts +++ b/packages/core/src/routes/log.test.ts @@ -1,25 +1,21 @@ -import type { LogCondition } from '@/queries/log'; -import logRoutes from '@/routes/log'; -import { createRequester } from '@/utils/test-utils'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; + +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; const mockBody = { type: 'a', payload: {}, createdAt: 123 }; const mockLog = { id: '1', ...mockBody }; const mockLogs = [mockLog, { id: '2', ...mockBody }]; -const countLogs = jest.fn(async (condition: LogCondition) => ({ - count: mockLogs.length, -})); -const findLogs = jest.fn( - async (limit: number, offset: number, condition: LogCondition) => mockLogs -); -const findLogById = jest.fn(async (id: string) => mockLog); - -jest.mock('@/queries/log', () => ({ - countLogs: async (condition: LogCondition) => countLogs(condition), - findLogs: async (limit: number, offset: number, condition: LogCondition) => - findLogs(limit, offset, condition), - findLogById: async (id: string) => findLogById(id), +const { countLogs, findLogs, findLogById } = mockEsm('#src/queries/log.js', () => ({ + countLogs: jest.fn().mockResolvedValue({ + count: mockLogs.length, + }), + findLogs: jest.fn().mockResolvedValue(mockLogs), + findLogById: jest.fn().mockResolvedValue(mockLog), })); +const logRoutes = await pickDefault(import('./log.js')); describe('logRoutes', () => { const logRequest = createRequester({ authedRoutes: logRoutes }); diff --git a/packages/core/src/routes/log.ts b/packages/core/src/routes/log.ts index d30fd9297..29772854b 100644 --- a/packages/core/src/routes/log.ts +++ b/packages/core/src/routes/log.ts @@ -1,11 +1,11 @@ import { Logs } from '@logto/schemas'; import { object, string } from 'zod'; -import koaGuard from '@/middleware/koa-guard'; -import koaPagination from '@/middleware/koa-pagination'; -import { countLogs, findLogById, findLogs } from '@/queries/log'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { countLogs, findLogById, findLogs } from '#src/queries/log.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; export default function logRoutes(router: T) { router.get( diff --git a/packages/core/src/routes/phrase.content-language.test.ts b/packages/core/src/routes/phrase.content-language.test.ts index ab0d61bee..642596862 100644 --- a/packages/core/src/routes/phrase.content-language.test.ts +++ b/packages/core/src/routes/phrase.content-language.test.ts @@ -1,51 +1,42 @@ -import en from '@logto/phrases-ui/lib/locales/en'; -import { Provider } from 'oidc-provider'; +import en from '@logto/phrases-ui/lib/locales/en.js'; +import { mockEsmWithActual, pickDefault } from '@logto/shared/esm'; -import { mockSignInExperience } from '@/__mocks__'; -import { trTrTag, zhCnTag, zhHkTag } from '@/__mocks__/custom-phrase'; -import phraseRoutes from '@/routes/phrase'; -import { createRequester } from '@/utils/test-utils'; +import { trTrTag, zhCnTag, zhHkTag } from '#src/__mocks__/custom-phrase.js'; +import { mockSignInExperience } from '#src/__mocks__/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createRequester } from '#src/utils/test-utils.js'; -const mockApplicationId = 'mockApplicationIdValue'; - -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({ - params: { client_id: mockApplicationId }, -})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - })), -})); +const { jest } = import.meta; const fallbackLanguage = trTrTag; const unsupportedLanguageX = 'xx-XX'; const unsupportedLanguageY = 'yy-YY'; -const findDefaultSignInExperience = jest.fn(async () => ({ - ...mockSignInExperience, - languageInfo: { - autoDetect: true, - fallbackLanguage, - }, -})); +const { findDefaultSignInExperience } = await mockEsmWithActual( + '#src/queries/sign-in-experience.js', + () => ({ + findDefaultSignInExperience: jest.fn(async () => ({ + ...mockSignInExperience, + languageInfo: { + autoDetect: true, + fallbackLanguage, + }, + })), + }) +); -jest.mock('@/queries/sign-in-experience', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), -})); - -jest.mock('@/queries/custom-phrase', () => ({ +await mockEsmWithActual('#src/queries/custom-phrase.js', () => ({ findAllCustomLanguageTags: async () => [trTrTag, zhCnTag], })); -jest.mock('@/lib/phrase', () => ({ - ...jest.requireActual('@/lib/phrase'), +await mockEsmWithActual('#src/lib/phrase.js', () => ({ getPhrase: jest.fn().mockResolvedValue(en), })); +const phraseRoutes = await pickDefault(import('./phrase.js')); const phraseRequest = createRequester({ anonymousRoutes: phraseRoutes, - provider: new Provider(''), + provider: createMockProvider(), }); afterEach(() => { diff --git a/packages/core/src/routes/phrase.test.ts b/packages/core/src/routes/phrase.test.ts index faa884f0b..413d28755 100644 --- a/packages/core/src/routes/phrase.test.ts +++ b/packages/core/src/routes/phrase.test.ts @@ -1,67 +1,51 @@ -import zhCN from '@logto/phrases-ui/lib/locales/zh-cn'; +import zhCN from '@logto/phrases-ui/lib/locales/zh-cn.js'; import type { SignInExperience } from '@logto/schemas'; -import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds'; -import { Provider } from 'oidc-provider'; +import { + adminConsoleApplicationId, + adminConsoleSignInExperience, +} from '@logto/schemas/lib/seeds/index.js'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; -import { mockSignInExperience } from '@/__mocks__'; -import { zhCnTag } from '@/__mocks__/custom-phrase'; -import * as detectLanguage from '@/i18n/detect-language'; -import phraseRoutes from '@/routes/phrase'; -import { createRequester } from '@/utils/test-utils'; +import { zhCnTag } from '#src/__mocks__/custom-phrase.js'; +import { mockSignInExperience } from '#src/__mocks__/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; -const mockApplicationId = 'mockApplicationIdValue'; - -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({ - params: { client_id: mockApplicationId }, -})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - })), -})); +const { jest } = import.meta; const customizedLanguage = zhCnTag; -const findDefaultSignInExperience = jest.fn( - async (): Promise => ({ - ...mockSignInExperience, - languageInfo: { - autoDetect: true, - fallbackLanguage: customizedLanguage, - }, - }) -); - -jest.mock('@/queries/sign-in-experience', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), +const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({ + findDefaultSignInExperience: jest.fn( + async (): Promise => ({ + ...mockSignInExperience, + languageInfo: { + autoDetect: true, + fallbackLanguage: customizedLanguage, + }, + }) + ), })); -const detectLanguageSpy = jest.spyOn(detectLanguage, 'default'); - -const findAllCustomLanguageTags = jest.fn(async () => [customizedLanguage]); -const findCustomPhraseByLanguageTag = jest.fn(async (tag: string) => ({})); - -jest.mock('@/queries/custom-phrase', () => ({ - findAllCustomLanguageTags: async () => findAllCustomLanguageTags(), - findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag), +const { default: detectLanguageSpy } = mockEsm('#src/i18n/detect-language.js', () => ({ + default: jest.fn().mockReturnValue([]), })); -const getPhrase = jest.fn(async (language: string, customLanguages: string[]) => zhCN); - -jest.mock('@/lib/phrase', () => ({ - ...jest.requireActual('@/lib/phrase'), - getPhrase: async (language: string, customLanguages: string[]) => - getPhrase(language, customLanguages), +const { findAllCustomLanguageTags } = mockEsm('#src/queries/custom-phrase.js', () => ({ + findAllCustomLanguageTags: jest.fn(async () => [customizedLanguage]), + findCustomPhraseByLanguageTag: jest.fn(async (tag: string) => ({})), })); +const { getPhrase } = await mockEsmWithActual('#src/lib/phrase.js', () => ({ + getPhrase: jest.fn(async () => zhCN), +})); + +const interactionDetails = jest.fn(); +const phraseRoutes = await pickDefault(import('./phrase.js')); + +const { createRequester } = await import('#src/utils/test-utils.js'); const phraseRequest = createRequester({ anonymousRoutes: phraseRoutes, - provider: new Provider(''), -}); - -afterEach(() => { - jest.clearAllMocks(); + provider: createMockProvider(interactionDetails), }); describe('when the application is admin-console', () => { @@ -71,6 +55,10 @@ describe('when the application is admin-console', () => { }); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should call interactionDetails', async () => { await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); expect(interactionDetails).toBeCalledTimes(1); @@ -101,6 +89,18 @@ describe('when the application is admin-console', () => { }); describe('when the application is not admin-console', () => { + beforeEach(() => { + interactionDetails.mockResolvedValue({ + params: {}, + jti: 'jti', + client_id: 'mockApplicationId', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('should call interactionDetails', async () => { await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); expect(interactionDetails).toBeCalledTimes(1); diff --git a/packages/core/src/routes/phrase.ts b/packages/core/src/routes/phrase.ts index fa9784a3f..3377360c2 100644 --- a/packages/core/src/routes/phrase.ts +++ b/packages/core/src/routes/phrase.ts @@ -1,13 +1,16 @@ import { isBuiltInLanguageTag } from '@logto/phrases-ui'; -import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds'; +import { + adminConsoleApplicationId, + adminConsoleSignInExperience, +} from '@logto/schemas/lib/seeds/index.js'; import type { Provider } from 'oidc-provider'; -import detectLanguage from '@/i18n/detect-language'; -import { getPhrase } from '@/lib/phrase'; -import { findAllCustomLanguageTags } from '@/queries/custom-phrase'; -import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; +import detectLanguage from '#src/i18n/detect-language.js'; +import { getPhrase } from '#src/lib/phrase.js'; +import { findAllCustomLanguageTags } from '#src/queries/custom-phrase.js'; +import { findDefaultSignInExperience } from '#src/queries/sign-in-experience.js'; -import type { AnonymousRouter } from './types'; +import type { AnonymousRouter } from './types.js'; const getLanguageInfo = async (applicationId: unknown) => { if (applicationId === adminConsoleApplicationId) { diff --git a/packages/core/src/routes/profile.test.ts b/packages/core/src/routes/profile.test.ts new file mode 100644 index 000000000..5d2dcd2ac --- /dev/null +++ b/packages/core/src/routes/profile.test.ts @@ -0,0 +1,481 @@ +import type { CreateUser, User } from '@logto/schemas'; +import { ConnectorType } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual } from '@logto/shared/esm'; +import { getUnixTime } from 'date-fns'; + +import { + mockLogtoConnectorList, + mockPasswordEncrypted, + mockUser, + mockUserResponse, +} from '#src/__mocks__/index.js'; +import type { SocialUserInfo } from '#src/connectors/types.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +const getLogtoConnectorById = jest.fn(async () => ({ + dbEntry: { enabled: true }, + metadata: { id: 'connectorId', target: 'mock_social' }, + type: ConnectorType.Social, + getAuthorizationUri: jest.fn(async () => ''), +})); + +mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectors: mockLogtoConnectorList, + getLogtoConnectorById, +})); + +const { getUserInfoByAuthCode } = await mockEsmWithActual('#src/lib/social.js', () => ({ + findSocialRelatedUser: jest.fn(async () => [{ id: 'user1', identities: {}, isSuspended: false }]), + getUserInfoByAuthCode: jest.fn(), +})); + +const { + findUserById, + hasUser, + hasUserWithEmail, + hasUserWithPhone, + updateUserById, + deleteUserIdentity, +} = await mockEsmWithActual('#src/queries/user.js', () => ({ + findUserById: jest.fn(async (): Promise => mockUser), + hasUser: jest.fn(async () => false), + hasUserWithEmail: jest.fn(async () => false), + hasUserWithPhone: jest.fn(async () => false), + updateUserById: jest.fn( + async (_, data: Partial): Promise => ({ + ...mockUser, + ...data, + }) + ), + deleteUserIdentity: jest.fn(), +})); + +const { encryptUserPassword } = await mockEsmWithActual('#src/lib/user.js', () => ({ + encryptUserPassword: jest.fn(async (password: string) => ({ + passwordEncrypted: password + '_user1', + passwordEncryptionMethod: 'Argon2i', + })), +})); + +mockEsm('#src/queries/sign-in-experience.js', () => ({ + findDefaultSignInExperience: async () => ({ + signUp: { + identifier: [], + password: false, + verify: false, + }, + }), +})); + +const { argon2Verify } = mockEsm('hash-wasm', () => ({ + argon2Verify: jest.fn(async (password: string) => password === mockPasswordEncrypted), +})); + +const { default: profileRoutes, profileRoute } = await import('./profile.js'); + +describe('session -> profileRoutes', () => { + const provider = createMockProvider(); + // @ts-expect-error for testing + const mockGetSession: jest.Mock = jest.spyOn(provider.Session, 'get'); + const sessionRequest = createRequester({ + anonymousRoutes: profileRoutes, + provider, + middlewares: [ + async (ctx, next) => { + ctx.addLogContext = jest.fn(); + ctx.log = jest.fn(); + + return next(); + }, + ], + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetSession.mockImplementation(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 60, + })); + }); + + describe('GET /session/profile', () => { + it('should return current user data', async () => { + const response = await sessionRequest.get(profileRoute); + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual(mockUserResponse); + }); + + it('should throw when the user is not authenticated', async () => { + mockGetSession.mockImplementationOnce( + jest.fn(async () => ({ + accountId: undefined, + loginTs: undefined, + })) + ); + + const response = await sessionRequest.get(profileRoute); + expect(response.statusCode).toEqual(401); + }); + }); + + describe('PATCH /session/profile', () => { + it('should update current user with display name, avatar and custom data', async () => { + const updatedUserInfo = { + name: 'John Doe', + avatar: 'https://new-avatar.cdn.com', + customData: { gender: 'male', age: '30' }, + }; + + const response = await sessionRequest.patch(profileRoute).send(updatedUserInfo); + + expect(updateUserById).toBeCalledWith('id', expect.objectContaining(updatedUserInfo)); + expect(response.statusCode).toEqual(204); + }); + + it('should throw when the user is not authenticated', async () => { + mockGetSession.mockImplementationOnce( + jest.fn(async () => ({ + accountId: undefined, + loginTs: undefined, + })) + ); + + const response = await sessionRequest.patch(profileRoute).send({ name: 'John Doe' }); + expect(response.statusCode).toEqual(401); + }); + }); + + describe('PATCH /session/profile/username', () => { + it('should throw if last authentication time is over 10 mins ago', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest + .patch(`${profileRoute}/username`) + .send({ username: 'test' }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should update username with the new value', async () => { + const newUsername = 'charles'; + + const response = await sessionRequest + .patch(`${profileRoute}/username`) + .send({ username: newUsername }); + + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({ ...mockUserResponse, username: newUsername }); + }); + + it('should throw when username is already in use', async () => { + hasUser.mockImplementationOnce(async () => true); + + const response = await sessionRequest + .patch(`${profileRoute}/username`) + .send({ username: 'test' }); + + expect(response.statusCode).toEqual(422); + }); + }); + + describe('PATCH /session/profile/password', () => { + it('should throw if last authentication time is over 10 mins ago', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest + .patch(`${profileRoute}/password`) + .send({ password: mockPasswordEncrypted }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should update password with the new value', async () => { + const response = await sessionRequest + .patch(`${profileRoute}/password`) + .send({ password: mockPasswordEncrypted }); + + expect(updateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + passwordEncrypted: 'a1b2c3_user1', + passwordEncryptionMethod: 'Argon2i', + }) + ); + expect(response.statusCode).toEqual(204); + }); + + it('should throw if new password is identical to old password', async () => { + encryptUserPassword.mockImplementationOnce(async (password: string) => ({ + passwordEncrypted: password, + passwordEncryptionMethod: 'Argon2i', + })); + argon2Verify.mockResolvedValueOnce(true); + + const response = await sessionRequest + .patch(`${profileRoute}/password`) + .send({ password: 'password' }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + }); + + describe('email related APIs', () => { + it('should throw if last authentication time is over 10 mins ago on linking email', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest + .patch(`${profileRoute}/email`) + .send({ primaryEmail: 'test@logto.io' }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should link email address to the user profile', async () => { + const mockEmailAddress = 'bar@logto.io'; + const response = await sessionRequest + .patch(`${profileRoute}/email`) + .send({ primaryEmail: mockEmailAddress }); + + expect(updateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + primaryEmail: mockEmailAddress, + }) + ); + expect(response.statusCode).toEqual(204); + }); + + it('should throw when email address already exists', async () => { + hasUserWithEmail.mockImplementationOnce(async () => true); + + const response = await sessionRequest + .patch(`${profileRoute}/email`) + .send({ primaryEmail: mockUser.primaryEmail }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should throw when email address is invalid', async () => { + hasUserWithEmail.mockImplementationOnce(async () => true); + + const response = await sessionRequest + .patch(`${profileRoute}/email`) + .send({ primaryEmail: 'test' }); + + expect(response.statusCode).toEqual(400); + expect(updateUserById).not.toBeCalled(); + }); + + it('should throw if last authentication time is over 10 mins ago on unlinking email', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest.delete(`${profileRoute}/email`); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should unlink email address from user', async () => { + const response = await sessionRequest.delete(`${profileRoute}/email`); + expect(response.statusCode).toEqual(204); + expect(updateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + primaryEmail: null, + }) + ); + }); + + it('should throw when no email address found in user on unlinking email', async () => { + findUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryEmail: null })); + const response = await sessionRequest.delete(`${profileRoute}/email`); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + }); + + describe('phone related APIs', () => { + it('should throw if last authentication time is over 10 mins ago on linking phone number', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const updateResponse = await sessionRequest + .patch(`${profileRoute}/phone`) + .send({ primaryPhone: '6533333333' }); + + expect(updateResponse.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should link phone number to the user profile', async () => { + const mockPhoneNumber = '6533333333'; + const response = await sessionRequest + .patch(`${profileRoute}/phone`) + .send({ primaryPhone: mockPhoneNumber }); + + expect(updateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + primaryPhone: mockPhoneNumber, + }) + ); + expect(response.statusCode).toEqual(204); + }); + + it('should throw when phone number already exists on linking phone number', async () => { + hasUserWithPhone.mockImplementationOnce(async () => true); + + const response = await sessionRequest + .patch(`${profileRoute}/phone`) + .send({ primaryPhone: mockUser.primaryPhone }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should throw when phone number is invalid', async () => { + hasUserWithPhone.mockImplementationOnce(async () => true); + + const response = await sessionRequest + .patch(`${profileRoute}/phone`) + .send({ primaryPhone: 'invalid' }); + + expect(response.statusCode).toEqual(400); + expect(updateUserById).not.toBeCalled(); + }); + + it('should throw if last authentication time is over 10 mins ago on unlinking phone number', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest.delete(`${profileRoute}/phone`); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should unlink phone number from user', async () => { + const response = await sessionRequest.delete(`${profileRoute}/phone`); + expect(response.statusCode).toEqual(204); + expect(updateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + primaryPhone: null, + }) + ); + }); + + it('should throw when no phone number found in user on unlinking phone number', async () => { + findUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryPhone: null })); + const response = await sessionRequest.delete(`${profileRoute}/phone`); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + }); + + describe('social identities related APIs', () => { + it('should update social identities for current user', async () => { + const mockSocialUserInfo: SocialUserInfo = { + id: 'social_user_id', + name: 'John Doe', + avatar: 'https://avatar.social.com/johndoe', + email: 'johndoe@social.com', + phone: '123456789', + }; + getUserInfoByAuthCode.mockReturnValueOnce(mockSocialUserInfo); + + const response = await sessionRequest.patch(`${profileRoute}/identities`).send({ + connectorId: 'connectorId', + data: { code: '123456' }, + }); + + expect(response.statusCode).toEqual(204); + expect(updateUserById).toBeCalledWith( + 'id', + expect.objectContaining({ + identities: { + ...mockUser.identities, + mock_social: { userId: mockSocialUserInfo.id, details: mockSocialUserInfo }, + }, + }) + ); + }); + + it('should throw when the user is not authenticated', async () => { + mockGetSession.mockImplementationOnce( + jest.fn(async () => ({ + accountId: undefined, + loginTs: undefined, + })) + ); + + const response = await sessionRequest.patch(`${profileRoute}/identities`).send({ + connectorId: 'connectorId', + data: { code: '123456' }, + }); + + expect(response.statusCode).toEqual(401); + expect(updateUserById).not.toBeCalled(); + }); + + it('should throw if last authentication time is over 10 mins ago on linking email', async () => { + mockGetSession.mockImplementationOnce(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 601, + })); + + const response = await sessionRequest + .patch(`${profileRoute}/identities`) + .send({ connectorId: 'connectorId', data: { code: '123456' } }); + + expect(response.statusCode).toEqual(422); + expect(updateUserById).not.toBeCalled(); + }); + + it('should unlink social identities from user', async () => { + findUserById.mockImplementationOnce(async () => ({ + ...mockUser, + identities: { + mock_social: { + userId: 'social_user_id', + details: { + id: 'social_user_id', + name: 'John Doe', + }, + }, + }, + })); + + const response = await sessionRequest.delete(`${profileRoute}/identities/mock_social`); + + expect(response.statusCode).toEqual(204); + expect(deleteUserIdentity).toBeCalledWith('id', 'mock_social'); + }); + }); +}); diff --git a/packages/core/src/routes/profile.ts b/packages/core/src/routes/profile.ts new file mode 100644 index 000000000..991322411 --- /dev/null +++ b/packages/core/src/routes/profile.ts @@ -0,0 +1,242 @@ +import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; +import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas'; +import { has } from '@silverhand/essentials'; +import { argon2Verify } from 'hash-wasm'; +import pick from 'lodash.pick'; +import type { Provider } from 'oidc-provider'; +import { object, string, unknown } from 'zod'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { checkSessionHealth } from '#src/lib/session.js'; +import { getUserInfoByAuthCode } from '#src/lib/social.js'; +import { checkIdentifierCollision, encryptUserPassword } from '#src/lib/user.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { deleteUserIdentity, findUserById, updateUserById } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { verificationTimeout } from './consts.js'; +import type { AnonymousRouter } from './types.js'; + +export const profileRoute = '/profile'; + +export default function profileRoutes(router: T, provider: Provider) { + router.get(profileRoute, async (ctx, next) => { + const { accountId: userId } = await provider.Session.get(ctx); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const user = await findUserById(userId); + + ctx.body = pick(user, ...userInfoSelectFields); + ctx.status = 200; + + return next(); + }); + + router.patch( + profileRoute, + koaGuard({ + body: object({ + name: string().nullable().optional(), + avatar: string().nullable().optional(), + customData: arbitraryObjectGuard.optional(), + }), + }), + async (ctx, next) => { + const { accountId: userId } = await provider.Session.get(ctx); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { name, avatar, customData } = ctx.guard.body; + + await updateUserById(userId, { name, avatar, customData }); + + ctx.status = 204; + + return next(); + } + ); + + router.patch( + `${profileRoute}/username`, + koaGuard({ + body: object({ username: string().regex(usernameRegEx) }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { username } = ctx.guard.body; + await checkIdentifierCollision({ username }, userId); + + const user = await updateUserById(userId, { username }, 'replace'); + ctx.body = pick(user, ...userInfoSelectFields); + + return next(); + } + ); + + router.patch( + `${profileRoute}/password`, + koaGuard({ + body: object({ password: string().regex(passwordRegEx) }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { password } = ctx.guard.body; + const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId); + + assertThat( + !oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })), + new RequestError({ code: 'user.same_password', status: 422 }) + ); + + const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); + + await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod }); + + ctx.status = 204; + + return next(); + } + ); + + router.patch( + `${profileRoute}/email`, + koaGuard({ + body: object({ primaryEmail: string().regex(emailRegEx) }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { primaryEmail } = ctx.guard.body; + + await checkIdentifierCollision({ primaryEmail }); + await updateUserById(userId, { primaryEmail }); + + ctx.status = 204; + + return next(); + } + ); + + router.delete(`${profileRoute}/email`, async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { primaryEmail } = await findUserById(userId); + + assertThat(primaryEmail, new RequestError({ code: 'user.email_not_exist', status: 422 })); + + await updateUserById(userId, { primaryEmail: null }); + + ctx.status = 204; + + return next(); + }); + + router.patch( + `${profileRoute}/phone`, + koaGuard({ + body: object({ primaryPhone: string().regex(phoneRegEx) }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { primaryPhone } = ctx.guard.body; + + await checkIdentifierCollision({ primaryPhone }); + await updateUserById(userId, { primaryPhone }); + + ctx.status = 204; + + return next(); + } + ); + + router.delete(`${profileRoute}/phone`, async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { primaryPhone } = await findUserById(userId); + + assertThat(primaryPhone, new RequestError({ code: 'user.phone_not_exist', status: 422 })); + + await updateUserById(userId, { primaryPhone: null }); + + ctx.status = 204; + + return next(); + }); + + router.patch( + `${profileRoute}/identities`, + koaGuard({ + body: object({ + connectorId: string(), + data: unknown(), + }), + }), + async (ctx, next) => { + const userId = await checkSessionHealth(ctx, provider, verificationTimeout); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { connectorId, data } = ctx.guard.body; + + const { + metadata: { target }, + } = await getLogtoConnectorById(connectorId); + + const socialUserInfo = await getUserInfoByAuthCode(connectorId, data); + const { identities } = await findUserById(userId); + + await updateUserById(userId, { + identities: { + ...identities, + [target]: { userId: socialUserInfo.id, details: socialUserInfo }, + }, + }); + + ctx.status = 204; + + return next(); + } + ); + + router.delete( + `${profileRoute}/identities/:target`, + koaGuard({ + params: object({ target: string() }), + }), + async (ctx, next) => { + const { accountId: userId } = await provider.Session.get(ctx); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { target } = ctx.guard.params; + const { identities } = await findUserById(userId); + + assertThat( + has(identities, target), + new RequestError({ code: 'user.identity_not_exist', status: 404 }) + ); + + await deleteUserIdentity(userId, target); + + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/resource.test.ts b/packages/core/src/routes/resource.test.ts index e36546b94..03da2cd92 100644 --- a/packages/core/src/routes/resource.test.ts +++ b/packages/core/src/routes/resource.test.ts @@ -1,34 +1,33 @@ import type { Resource, CreateResource } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; -import { mockResource } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockResource } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import resourceRoutes from './resource'; +const { jest } = import.meta; -jest.mock('@/queries/resource', () => ({ - findTotalNumberOfResources: jest.fn(async () => ({ count: 10 })), - findAllResources: jest.fn(async (): Promise => [mockResource]), - findResourceById: jest.fn(async (): Promise => mockResource), - insertResource: jest.fn( - async (body: CreateResource): Promise => ({ - ...mockResource, - ...body, - }) - ), - updateResourceById: jest.fn( - async (_, data: Partial): Promise => ({ - ...mockResource, - ...data, - }) - ), +mockEsm('#src/queries/resource.js', () => ({ + findTotalNumberOfResources: async () => ({ count: 10 }), + findAllResources: async (): Promise => [mockResource], + findResourceById: async (): Promise => mockResource, + insertResource: async (body: CreateResource): Promise => ({ + ...mockResource, + ...body, + }), + updateResourceById: async (_: unknown, data: Partial): Promise => ({ + ...mockResource, + ...data, + }), deleteResourceById: jest.fn(), })); -jest.mock('@logto/shared', () => ({ +mockEsm('@logto/shared', () => ({ // eslint-disable-next-line unicorn/consistent-function-scoping - buildIdGenerator: jest.fn(() => () => 'randomId'), + buildIdGenerator: () => () => 'randomId', })); +const resourceRoutes = await pickDefault(import('./resource.js')); + describe('resource routes', () => { const resourceRequest = createRequester({ authedRoutes: resourceRoutes }); diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index aa36bbf4f..43e9be35d 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -2,8 +2,8 @@ import { Resources } from '@logto/schemas'; import { buildIdGenerator } from '@logto/shared'; import { object, string } from 'zod'; -import koaGuard from '@/middleware/koa-guard'; -import koaPagination from '@/middleware/koa-pagination'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; import { findTotalNumberOfResources, findAllResources, @@ -11,9 +11,9 @@ import { insertResource, updateResourceById, deleteResourceById, -} from '@/queries/resource'; +} from '#src/queries/resource.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; const resourceId = buildIdGenerator(21); diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index 9e9ff88fa..a31447a40 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -1,13 +1,15 @@ import type { Role } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; -import { mockRole } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockRole } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import roleRoutes from './role'; +const { jest } = import.meta; -jest.mock('@/queries/roles', () => ({ +mockEsm('#src/queries/roles.js', () => ({ findAllRoles: jest.fn(async (): Promise => [mockRole]), })); +const roleRoutes = await pickDefault(import('./role.js')); describe('role routes', () => { const roleRequester = createRequester({ authedRoutes: roleRoutes }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index f2e5905e8..8043abf0f 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -1,6 +1,6 @@ -import { findAllRoles } from '@/queries/roles'; +import { findAllRoles } from '#src/queries/roles.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; export default function roleRoutes(router: T) { router.get('/roles', async (ctx, next) => { diff --git a/packages/core/src/routes/session/consts.ts b/packages/core/src/routes/session/consts.ts deleted file mode 100644 index 65b7777a8..000000000 --- a/packages/core/src/routes/session/consts.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const verificationTimeout = 10 * 60; // 10 mins. -export const continueSignInTimeout = 10 * 60; // 10 mins. diff --git a/packages/core/src/routes/session/continue.test.ts b/packages/core/src/routes/session/continue.test.ts index 76d4456fe..9471bdba2 100644 --- a/packages/core/src/routes/session/continue.test.ts +++ b/packages/core/src/routes/session/continue.test.ts @@ -2,10 +2,10 @@ import { PasscodeType } from '@logto/schemas'; import { addDays, subSeconds } from 'date-fns'; import { Provider } from 'oidc-provider'; -import { mockUser } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockUser } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import continueRoutes, { continueRoute } from './continue'; +import continueRoutes, { continueRoute } from './continue.js'; const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString(); const getVerificationStorageFromInteraction = jest.fn(); @@ -17,7 +17,7 @@ jest.mock('./utils', () => ({ getVerificationStorageFromInteraction: () => getVerificationStorageFromInteraction(), })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: jest.fn(), })); @@ -27,7 +27,7 @@ const hasUser = jest.fn(); const hasUserWithPhone = jest.fn(); const hasUserWithEmail = jest.fn(); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ updateUserById: async (...args: unknown[]) => updateUserById(...args), findUserById: async () => findUserById(), hasUser: async () => hasUser(), diff --git a/packages/core/src/routes/session/continue.ts b/packages/core/src/routes/session/continue.ts index f58093445..3b32e5d74 100644 --- a/packages/core/src/routes/session/continue.ts +++ b/packages/core/src/routes/session/continue.ts @@ -2,29 +2,29 @@ import { passwordRegEx, usernameRegEx } from '@logto/core-kit'; import type { Provider } from 'oidc-provider'; import { object, string } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; -import { encryptUserPassword } from '@/lib/user'; -import koaGuard from '@/middleware/koa-guard'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults, getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; +import { encryptUserPassword } from '#src/lib/user.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import { findUserById, hasUser, hasUserWithEmail, hasUserWithPhone, updateUserById, -} from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types'; -import { continueEmailSessionResultGuard, continueSmsSessionResultGuard } from './types'; +import type { AnonymousRouter } from '../types.js'; +import { continueEmailSessionResultGuard, continueSmsSessionResultGuard } from './types.js'; import { checkRequiredProfile, getContinueSignInResult, getRoutePrefix, getVerificationStorageFromInteraction, isUserPasswordSet, -} from './utils'; +} from './utils.js'; export const continueRoute = getRoutePrefix('sign-in', 'continue'); @@ -45,7 +45,7 @@ export default function continueRoutes(router: T, pro assertThat( !isUserPasswordSet(user), new RequestError({ - code: 'user.password_exists', + code: 'user.password_exists_in_profile', }) ); @@ -79,14 +79,14 @@ export default function continueRoutes(router: T, pro assertThat( !user.username, new RequestError({ - code: 'user.username_exists', + code: 'user.username_exists_in_profile', }) ); assertThat( !(await hasUser(username)), new RequestError({ - code: 'user.username_exists_register', + code: 'user.username_already_in_use', status: 422, }) ); @@ -116,14 +116,14 @@ export default function continueRoutes(router: T, pro assertThat( !user.primaryEmail, new RequestError({ - code: 'user.email_exists', + code: 'user.email_exists_in_profile', }) ); assertThat( !(await hasUserWithEmail(email)), new RequestError({ - code: 'user.email_exists_register', + code: 'user.email_already_in_use', status: 422, }) ); @@ -152,14 +152,14 @@ export default function continueRoutes(router: T, pro assertThat( !user.primaryPhone, new RequestError({ - code: 'user.sms_exists', + code: 'user.phone_exists_in_profile', }) ); assertThat( !(await hasUserWithPhone(phone)), new RequestError({ - code: 'user.phone_exists_register', + code: 'user.phone_already_in_use', status: 422, }) ); diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index e517b6068..f24680685 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -3,11 +3,15 @@ import { PasscodeType } from '@logto/schemas'; import { addDays, subDays } from 'date-fns'; import { Provider } from 'oidc-provider'; -import { mockPasswordEncrypted, mockSignInExperience, mockUserWithPassword } from '@/__mocks__'; -import RequestError from '@/errors/RequestError'; -import { createRequester } from '@/utils/test-utils'; +import { + mockPasswordEncrypted, + mockSignInExperience, + mockUserWithPassword, +} from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password'; +import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password.js'; const encryptUserPassword = jest.fn(async (password: string) => ({ passwordEncrypted: password + '_user1', @@ -19,13 +23,13 @@ const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience); const getYesterdayDate = () => subDays(Date.now(), 1); const getTomorrowDate = () => addDays(Date.now(), 1); -jest.mock('@/lib/user', () => ({ - ...jest.requireActual('@/lib/user'), +jest.mock('#src/lib/user.js', () => ({ + ...jest.requireActual('#src/lib/user.js'), encryptUserPassword: async (password: string) => encryptUserPassword(password), })); -jest.mock('@/queries/user', () => ({ - ...jest.requireActual('@/queries/user'), +jest.mock('#src/queries/user.js', () => ({ + ...jest.requireActual('#src/queries/user.js'), hasUserWithPhone: async (phone: string) => phone === '13000000000', findUserByPhone: async () => ({ userId: 'id' }), hasUserWithEmail: async (email: string) => email === 'a@a.com', @@ -34,12 +38,12 @@ jest.mock('@/queries/user', () => ({ updateUserById: async (...args: unknown[]) => updateUserById(...args), })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: async () => findDefaultSignInExperience(), })); const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } })); -jest.mock('@/lib/passcode', () => ({ +jest.mock('#src/lib/passcode.js', () => ({ createPasscode: async () => ({ userId: 'id' }), sendPasscode: async () => sendPasscode(), verifyPasscode: async (_a: unknown, _b: unknown, code: string) => { @@ -197,7 +201,7 @@ describe('session -> forgotPasswordRoutes', () => { const response = await sessionRequest .post(`${forgotPasswordRoute}/reset`) .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 400); + expect(response).toHaveProperty('status', 422); expect(updateUserById).toBeCalledTimes(0); }); it('should redirect when there was no old password', async () => { diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts index 2e979d101..4a49a260a 100644 --- a/packages/core/src/routes/session/forgot-password.ts +++ b/packages/core/src/routes/session/forgot-password.ts @@ -3,20 +3,20 @@ import { argon2Verify } from 'hash-wasm'; import type { Provider } from 'oidc-provider'; import { z } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { encryptUserPassword } from '@/lib/user'; -import koaGuard from '@/middleware/koa-guard'; -import { findUserById, updateUserById } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { encryptUserPassword } from '#src/lib/user.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { findUserById, updateUserById } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types'; -import { forgotPasswordSessionResultGuard } from './types'; +import type { AnonymousRouter } from '../types.js'; +import { forgotPasswordSessionResultGuard } from './types.js'; import { clearVerificationResult, getRoutePrefix, getVerificationStorageFromInteraction, checkValidateExpiration, -} from './utils'; +} from './utils.js'; export const forgotPasswordRoute = getRoutePrefix('forgot-password'); @@ -46,9 +46,8 @@ export default function forgotPasswordRoutes( const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId); assertThat( - !oldPasswordEncrypted || - (oldPasswordEncrypted && !(await argon2Verify({ password, hash: oldPasswordEncrypted }))), - new RequestError({ code: 'user.same_password', status: 400 }) + !oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })), + new RequestError({ code: 'user.same_password', status: 422 }) ); const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); diff --git a/packages/core/src/routes/session/index.test.ts b/packages/core/src/routes/session/index.test.ts index 0ab8896e3..3d3a4aedb 100644 --- a/packages/core/src/routes/session/index.test.ts +++ b/packages/core/src/routes/session/index.test.ts @@ -1,11 +1,11 @@ import type { User } from '@logto/schemas'; -import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; +import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds/index.js'; import { Provider } from 'oidc-provider'; -import { mockUser } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockUser } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import sessionRoutes from '.'; +import sessionRoutes from './index.js'; const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); @@ -16,7 +16,7 @@ const grantAddResourceScope = jest.fn(); const interactionResult = jest.fn(async () => 'redirectTo'); const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ findUserById: async () => findUserById(), updateUserById: async (...args: unknown[]) => updateUserById(...args), })); diff --git a/packages/core/src/routes/session/index.ts b/packages/core/src/routes/session/index.ts index bdae57efa..63607d43a 100644 --- a/packages/core/src/routes/session/index.ts +++ b/packages/core/src/routes/session/index.ts @@ -2,24 +2,24 @@ import path from 'path'; import type { LogtoErrorCode } from '@logto/phrases'; import { UserRole } from '@logto/schemas'; -import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; +import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds/index.js'; import { conditional } from '@silverhand/essentials'; import type { Provider } from 'oidc-provider'; import { object, string } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { assignInteractionResults, saveUserFirstConsentedAppId } from '@/lib/session'; -import { findUserById } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/lib/session.js'; +import { findUserById } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types'; -import continueRoutes from './continue'; -import forgotPasswordRoutes from './forgot-password'; -import koaGuardSessionAction from './middleware/koa-guard-session-action'; -import passwordRoutes from './password'; -import passwordlessRoutes from './passwordless'; -import socialRoutes from './social'; -import { getRoutePrefix } from './utils'; +import type { AnonymousRouter } from '../types.js'; +import continueRoutes from './continue.js'; +import forgotPasswordRoutes from './forgot-password.js'; +import koaGuardSessionAction from './middleware/koa-guard-session-action.js'; +import passwordRoutes from './password.js'; +import passwordlessRoutes from './passwordless.js'; +import socialRoutes from './social.js'; +import { getRoutePrefix } from './utils.js'; export default function sessionRoutes(router: T, provider: Provider) { router.use(getRoutePrefix('sign-in'), koaGuardSessionAction(provider, 'sign-in')); @@ -51,8 +51,7 @@ export default function sessionRoutes(router: T, prov const { accountId } = session; - // Temp solution before migrating to RBAC. Block non-admin user from consent to admin console - + // Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console if (String(client_id) === adminConsoleApplicationId) { const { roleNames } = await findUserById(accountId); @@ -105,6 +104,5 @@ export default function sessionRoutes(router: T, prov passwordlessRoutes(router, provider); socialRoutes(router, provider); continueRoutes(router, provider); - forgotPasswordRoutes(router, provider); } diff --git a/packages/core/src/routes/session/middleware/koa-guard-session-action.ts b/packages/core/src/routes/session/middleware/koa-guard-session-action.ts index ea1844265..4489a3f68 100644 --- a/packages/core/src/routes/session/middleware/koa-guard-session-action.ts +++ b/packages/core/src/routes/session/middleware/koa-guard-session-action.ts @@ -1,13 +1,13 @@ import { SignInMode } from '@logto/schemas'; -import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; +import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds/index.js'; import type { MiddlewareType } from 'koa'; import type { Provider } from 'oidc-provider'; import { errors } from 'oidc-provider'; -import RequestError from '@/errors/RequestError'; -import { getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; +import assertThat from '#src/utils/assert-that.js'; export default function koaGuardSessionAction( provider: Provider, diff --git a/packages/core/src/routes/session/middleware/passwordless-action.ts b/packages/core/src/routes/session/middleware/passwordless-action.ts index 9709b7a18..f83863ecd 100644 --- a/packages/core/src/routes/session/middleware/passwordless-action.ts +++ b/packages/core/src/routes/session/middleware/passwordless-action.ts @@ -1,28 +1,28 @@ -import { PasscodeType, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { PasscodeType, SignInIdentifier } from '@logto/schemas'; import type { MiddlewareType } from 'koa'; import type { Provider } from 'oidc-provider'; -import RequestError from '@/errors/RequestError'; -import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; -import { generateUserId, insertUser } from '@/lib/user'; -import type { WithLogContext } from '@/middleware/koa-log'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults, getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; +import { generateUserId, insertUser } from '#src/lib/user.js'; +import type { WithLogContext } from '#src/middleware/koa-log.js'; import { hasUserWithPhone, hasUserWithEmail, findUserByPhone, findUserByEmail, updateUserById, -} from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import { smsSessionResultGuard, emailSessionResultGuard } from '../types'; +import { smsSessionResultGuard, emailSessionResultGuard } from '../types.js'; import { getVerificationStorageFromInteraction, getPasswordlessRelatedLogType, checkValidateExpiration, checkRequiredProfile, -} from '../utils'; +} from '../utils.js'; export const smsSignInAction = ( provider: Provider @@ -56,7 +56,7 @@ export const smsSignInAction = mockUser); const hasUser = jest.fn(async (username: string) => username === 'username1'); @@ -15,7 +15,7 @@ const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); const hasActiveUsers = jest.fn(async () => true); const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ findUserById: async () => findUserById(), findUserByIdentity: async () => ({ id: mockUser.id, identities: {} }), findUserByPhone: async () => mockUser, @@ -34,11 +34,11 @@ jest.mock('@/queries/user', () => ({ }, })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: async () => findDefaultSignInExperience(), })); -jest.mock('@/lib/user', () => ({ +jest.mock('#src/lib/user.js', () => ({ async verifyUserPassword(user: User) { return user; }, @@ -51,8 +51,8 @@ jest.mock('@/lib/user', () => ({ insertUser: async (...args: unknown[]) => insertUser(...args), })); -jest.mock('@/lib/session', () => ({ - ...jest.requireActual('@/lib/session'), +jest.mock('#src/lib/session.js', () => ({ + ...jest.requireActual('#src/lib/session.js'), getApplicationIdFromInteraction: jest.fn(), })); @@ -239,7 +239,7 @@ describe('session -> password routes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], }, }); @@ -283,7 +283,7 @@ describe('session -> password routes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], }, }); diff --git a/packages/core/src/routes/session/password.ts b/packages/core/src/routes/session/password.ts index 9b7d31c03..ef43a8d30 100644 --- a/packages/core/src/routes/session/password.ts +++ b/packages/core/src/routes/session/password.ts @@ -1,25 +1,25 @@ import { passwordRegEx, usernameRegEx } from '@logto/core-kit'; -import { SignInIdentifier, SignUpIdentifier, UserRole } from '@logto/schemas'; -import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; +import { SignInIdentifier, UserRole } from '@logto/schemas'; +import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds/index.js'; import type { Provider } from 'oidc-provider'; import { object, string } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; -import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user'; -import koaGuard from '@/middleware/koa-guard'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults, getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; +import { encryptUserPassword, generateUserId, insertUser } from '#src/lib/user.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import { findUserByEmail, findUserByPhone, findUserByUsername, hasActiveUsers, hasUser, -} from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types'; -import { checkRequiredProfile, getRoutePrefix, signInWithPassword } from './utils'; +import type { AnonymousRouter } from '../types.js'; +import { checkRequiredProfile, getRoutePrefix, signInWithPassword } from './utils.js'; export const registerRoute = getRoutePrefix('register', 'password'); export const signInRoute = getRoutePrefix('sign-in', 'password'); @@ -108,7 +108,7 @@ export default function passwordRoutes(router: T, pro await getApplicationIdFromInteraction(ctx, provider) ); assertThat( - signInExperience.signUp.identifier === SignUpIdentifier.Username, + signInExperience.signUp.identifiers.includes(SignInIdentifier.Username), new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422, @@ -118,7 +118,7 @@ export default function passwordRoutes(router: T, pro assertThat( !(await hasUser(username)), new RequestError({ - code: 'user.username_exists_register', + code: 'user.username_already_in_use', status: 422, }) ); @@ -146,7 +146,7 @@ export default function passwordRoutes(router: T, pro await getApplicationIdFromInteraction(ctx, provider) ); assertThat( - signInExperience.signUp.identifier === SignUpIdentifier.Username, + signInExperience.signUp.identifiers.includes(SignInIdentifier.Username), new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422, @@ -156,7 +156,7 @@ export default function passwordRoutes(router: T, pro assertThat( !(await hasUser(username)), new RequestError({ - code: 'user.username_exists_register', + code: 'user.username_already_in_use', status: 422, }) ); diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index 27e5d74da..e4ca9411e 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -1,17 +1,17 @@ /* eslint-disable max-lines */ import type { User } from '@logto/schemas'; -import { PasscodeType, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { PasscodeType, SignInIdentifier } from '@logto/schemas'; import type { Nullable } from '@silverhand/essentials'; import { addDays, addSeconds, subDays } from 'date-fns'; import { Provider } from 'oidc-provider'; -import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__'; -import RequestError from '@/errors/RequestError'; -import { createRequester } from '@/utils/test-utils'; +import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import { verificationTimeout } from './consts'; -import * as passwordlessActions from './middleware/passwordless-action'; -import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless'; +import { verificationTimeout } from '../consts.js'; +import * as passwordlessActions from './middleware/passwordless-action.js'; +import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless.js'; const insertUser = jest.fn(async (..._args: unknown[]) => mockUser); const findUserById = jest.fn(async (): Promise => mockUser); @@ -22,24 +22,24 @@ const findDefaultSignInExperience = jest.fn(async () => ({ ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], password: false, verify: true, }, })); const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString(); -jest.mock('@/lib/user', () => ({ +jest.mock('#src/lib/user.js', () => ({ generateUserId: () => 'user1', insertUser: async (...args: unknown[]) => insertUser(...args), })); -jest.mock('@/lib/session', () => ({ - ...jest.requireActual('@/lib/session'), +jest.mock('#src/lib/session.js', () => ({ + ...jest.requireActual('#src/lib/session.js'), getApplicationIdFromInteraction: jest.fn(), })); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ findUserById: async () => findUserById(), findUserByPhone: async () => findUserByPhone(), findUserByEmail: async () => findUserByEmail(), @@ -49,7 +49,7 @@ jest.mock('@/queries/user', () => ({ hasUserWithEmail: async (email: string) => email === 'a@a.com', })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: async () => findDefaultSignInExperience(), })); const smsSignInActionSpy = jest.spyOn(passwordlessActions, 'smsSignInAction'); @@ -59,7 +59,7 @@ const emailRegisterActionSpy = jest.spyOn(passwordlessActions, 'emailRegisterAct const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } })); const createPasscode = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); -jest.mock('@/lib/passcode', () => ({ +jest.mock('#src/lib/passcode.js', () => ({ createPasscode: async (..._args: unknown[]) => createPasscode(..._args), sendPasscode: async () => sendPasscode(), verifyPasscode: async (_a: unknown, _b: unknown, code: string) => { @@ -554,7 +554,7 @@ describe('session -> passwordlessRoutes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], password: false, verify: true, }, @@ -709,7 +709,7 @@ describe('session -> passwordlessRoutes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Sms, + identifiers: [SignInIdentifier.Sms], password: false, }, }); @@ -822,7 +822,7 @@ describe('session -> passwordlessRoutes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], }, }); @@ -837,7 +837,7 @@ describe('session -> passwordlessRoutes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Email, + identifiers: [SignInIdentifier.Email], password: false, }, }); @@ -950,7 +950,7 @@ describe('session -> passwordlessRoutes', () => { ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Sms, + identifiers: [SignInIdentifier.Sms], }, }); diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index 61f55326a..4b918c897 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -3,21 +3,25 @@ import { PasscodeType } from '@logto/schemas'; import type { Provider } from 'oidc-provider'; import { object, string } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode'; -import koaGuard from '@/middleware/koa-guard'; -import { findUserByEmail, findUserByPhone } from '@/queries/user'; -import { passcodeTypeGuard } from '@/routes/session/types'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createPasscode, sendPasscode, verifyPasscode } from '#src/lib/passcode.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { findUserByEmail, findUserByPhone } from '#src/queries/user.js'; +import { passcodeTypeGuard } from '#src/routes/session/types.js'; +import assertThat from '#src/utils/assert-that.js'; -import type { AnonymousRouter } from '../types'; +import type { AnonymousRouter } from '../types.js'; import { smsSignInAction, emailSignInAction, smsRegisterAction, emailRegisterAction, -} from './middleware/passwordless-action'; -import { assignVerificationResult, getPasswordlessRelatedLogType, getRoutePrefix } from './utils'; +} from './middleware/passwordless-action.js'; +import { + assignVerificationResult, + getPasswordlessRelatedLogType, + getRoutePrefix, +} from './utils.js'; export const registerRoute = getRoutePrefix('register', 'passwordless'); export const signInRoute = getRoutePrefix('sign-in', 'passwordless'); @@ -101,7 +105,7 @@ export default function passwordlessRoutes( if (flow === PasscodeType.ForgotPassword) { const user = await findUserByPhone(phone); - assertThat(user, new RequestError({ code: 'user.phone_not_exists', status: 404 })); + assertThat(user, new RequestError({ code: 'user.phone_not_exist', status: 404 })); await assignVerificationResult(ctx, provider, { flow, userId: user.id }); ctx.status = 204; @@ -151,7 +155,7 @@ export default function passwordlessRoutes( if (flow === PasscodeType.ForgotPassword) { const user = await findUserByEmail(email); - assertThat(user, new RequestError({ code: 'user.email_not_exists', status: 404 })); + assertThat(user, new RequestError({ code: 'user.email_not_exist', status: 404 })); await assignVerificationResult(ctx, provider, { flow, userId: user.id }); ctx.status = 204; diff --git a/packages/core/src/routes/session/social.bind-social.test.ts b/packages/core/src/routes/session/social.bind-social.test.ts index 646218d20..c67990007 100644 --- a/packages/core/src/routes/session/social.bind-social.test.ts +++ b/packages/core/src/routes/session/social.bind-social.test.ts @@ -1,21 +1,20 @@ import { ConnectorType } from '@logto/connector-kit'; import type { User } from '@logto/schemas'; -import { SignUpIdentifier } from '@logto/schemas'; import { Provider } from 'oidc-provider'; -import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '@/__mocks__'; -import { getLogtoConnectorById } from '@/connectors'; -import RequestError from '@/errors/RequestError'; -import { createRequester } from '@/utils/test-utils'; +import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '#src/__mocks__/index.js'; +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import socialRoutes, { registerRoute } from './social'; +import socialRoutes, { registerRoute } from './social.js'; const findSocialRelatedUser = jest.fn(async () => [ 'phone', { id: 'user1', identities: {}, isSuspended: false }, ]); -jest.mock('@/lib/social', () => ({ - ...jest.requireActual('@/lib/social'), +jest.mock('#src/lib/social.js', () => ({ + ...jest.requireActual('#src/lib/social.js'), findSocialRelatedUser: async () => findSocialRelatedUser(), async getUserInfoByAuthCode(connectorId: string, data: { code: string }) { if (connectorId === '_connectorId') { @@ -40,7 +39,7 @@ const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); const findUserByIdentity = jest.fn(async () => mockUser); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ findUserById: async () => findUserById(), findUserByIdentity: async () => findUserByIdentity(), updateUserById: async (...args: unknown[]) => updateUserById(...args), @@ -48,17 +47,17 @@ jest.mock('@/queries/user', () => ({ target === 'connectorTarget' && userId === mockUser.id, })); -jest.mock('@/lib/user', () => ({ +jest.mock('#src/lib/user.js', () => ({ generateUserId: () => 'user1', insertUser: async (...args: unknown[]) => insertUser(...args), })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: async () => ({ ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.None, + identifiers: [], }, }), })); @@ -84,7 +83,7 @@ const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => { }; }); -jest.mock('@/connectors', () => ({ +jest.mock('#src/connectors.js', () => ({ getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList), getLogtoConnectorById: jest.fn(async (connectorId: string) => { const connector = await getLogtoConnectorByIdHelper(connectorId); diff --git a/packages/core/src/routes/session/social.test.ts b/packages/core/src/routes/session/social.test.ts index 1b4aec636..50840aea4 100644 --- a/packages/core/src/routes/session/social.test.ts +++ b/packages/core/src/routes/session/social.test.ts @@ -1,23 +1,22 @@ import { ConnectorType } from '@logto/connector-kit'; import type { User } from '@logto/schemas'; -import { SignUpIdentifier } from '@logto/schemas'; import { Provider } from 'oidc-provider'; -import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '@/__mocks__'; -import { getLogtoConnectorById } from '@/connectors'; -import RequestError from '@/errors/RequestError'; -import { createRequester } from '@/utils/test-utils'; +import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '#src/__mocks__/index.js'; +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import type { SocialUserInfo } from '#src/connectors/types.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import socialRoutes, { registerRoute, signInRoute } from './social'; +import socialRoutes, { signInRoute } from './social.js'; const findSocialRelatedUser = jest.fn(async () => [ 'phone', { id: 'user1', identities: {}, isSuspended: false }, ]); -jest.mock('@/lib/social', () => ({ - ...jest.requireActual('@/lib/social'), - findSocialRelatedUser: async () => findSocialRelatedUser(), - async getUserInfoByAuthCode(connectorId: string, data: { code: string }) { + +const getUserInfoByAuthCode = jest.fn( + async (connectorId: string, data: { code: string }): Promise => { if (connectorId === '_connectorId') { throw new RequestError({ code: 'session.invalid_connector_id', @@ -33,14 +32,21 @@ jest.mock('@/lib/social', () => ({ // This mocks the case that can not get userInfo with access token and auth code // (most likely third-party social connectors' problem). throw new Error(' '); - }, + } +); + +jest.mock('#src/lib/social.js', () => ({ + ...jest.requireActual('#src/lib/social.js'), + findSocialRelatedUser: async () => findSocialRelatedUser(), + getUserInfoByAuthCode: async (connectorId: string, data: { code: string }) => + getUserInfoByAuthCode(connectorId, data), })); const insertUser = jest.fn(async (..._args: unknown[]) => mockUser); const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserByIdentity = jest.fn(async () => mockUser); +const findUserByIdentity = jest.fn().mockResolvedValue(mockUser); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ findUserById: async () => findUserById(), findUserByIdentity: async () => findUserByIdentity(), updateUserById: async (...args: unknown[]) => updateUserById(...args), @@ -48,17 +54,17 @@ jest.mock('@/queries/user', () => ({ target === 'connectorTarget' && userId === mockUser.id, })); -jest.mock('@/lib/user', () => ({ +jest.mock('#src/lib/user.js', () => ({ generateUserId: () => 'user1', insertUser: async (...args: unknown[]) => insertUser(...args), })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: async () => ({ ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.None, + identifiers: [], }, }), })); @@ -84,7 +90,7 @@ const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => { }; }); -jest.mock('@/connectors', () => ({ +jest.mock('#src/connectors.js', () => ({ getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList), getLogtoConnectorById: jest.fn(async (connectorId: string) => { const connector = await getLogtoConnectorByIdHelper(connectorId); @@ -163,15 +169,6 @@ describe('session -> socialRoutes', () => { expect(response.statusCode).toEqual(400); }); - it('throw error when connector is disabled', async () => { - const response = await sessionRequest.post(`${signInRoute}`).send({ - connectorId: 'social_disabled', - state: 'state', - redirectUri: 'https://logto.dev', - }); - expect(response.statusCode).toEqual(400); - }); - it('throw error when no social connector is found', async () => { const response = await sessionRequest.post(`${signInRoute}`).send({ connectorId: 'others', @@ -184,10 +181,14 @@ describe('session -> socialRoutes', () => { describe('POST /session/sign-in/social/auth', () => { const connectorTarget = 'connectorTarget'; + afterEach(() => { + jest.clearAllMocks(); + }); it('throw error when auth code is wrong', async () => { (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ metadata: { target: connectorTarget }, + dbEntry: { syncProfile: false }, }); const response = await sessionRequest.post(`${signInRoute}/auth`).send({ connectorId: 'connectorId', @@ -201,6 +202,7 @@ describe('session -> socialRoutes', () => { it('throw error when code is provided but connector can not be found', async () => { (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ metadata: { target: connectorTarget }, + dbEntry: { syncProfile: false }, }); const response = await sessionRequest.post(`${signInRoute}/auth`).send({ connectorId: '_connectorId', @@ -214,6 +216,7 @@ describe('session -> socialRoutes', () => { it('get and add user info with auth code, as well as assign result and redirect', async () => { (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ metadata: { target: connectorTarget }, + dbEntry: { syncProfile: false }, }); const response = await sessionRequest.post(`${signInRoute}/auth`).send({ connectorId: 'connectorId', @@ -244,6 +247,7 @@ describe('session -> socialRoutes', () => { it('throw error when user is suspended', async () => { (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ metadata: { target: connectorTarget }, + dbEntry: { syncProfile: false }, }); findUserByIdentity.mockResolvedValueOnce({ ...mockUser, @@ -261,9 +265,10 @@ describe('session -> socialRoutes', () => { }); it('throw error when identity exists', async () => { - const wrongConnectorTarget = 'wrongConnectorTarget'; + findUserByIdentity.mockResolvedValueOnce(null); (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: wrongConnectorTarget }, + metadata: { target: connectorTarget }, + dbEntry: { syncProfile: false }, }); const response = await sessionRequest.post(`${signInRoute}/auth`).send({ connectorId: '_connectorId_', @@ -283,6 +288,58 @@ describe('session -> socialRoutes', () => { ); expect(response.statusCode).toEqual(422); }); + + it('should update `name` and `avatar` if exists when `syncProfile` is set to be true', async () => { + (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ + metadata: { target: connectorTarget }, + dbEntry: { syncProfile: true }, + }); + findUserByIdentity.mockResolvedValueOnce(mockUser); + getUserInfoByAuthCode.mockResolvedValueOnce({ + ...mockUser, + name: 'new_name', + avatar: 'new_avatar', + }); + await sessionRequest.post(`${signInRoute}/auth`).send({ + connectorId: 'connectorId', + data: { + state: 'state', + redirectUri: 'https://logto.dev', + code: '123456', + }, + }); + expect(updateUserById).toHaveBeenCalledWith( + mockUser.id, + expect.objectContaining({ name: 'new_name', avatar: 'new_avatar' }) + ); + }); + + it('should not update `name` and `avatar` if exists when `syncProfile` is set to be false', async () => { + (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ + metadata: { target: connectorTarget }, + dbEntry: { syncProfile: true }, + }); + findUserByIdentity.mockResolvedValueOnce(mockUser); + getUserInfoByAuthCode.mockResolvedValueOnce({ + ...mockUser, + name: 'new_name', + avatar: 'new_avatar', + }); + await sessionRequest.post(`${signInRoute}/auth`).send({ + connectorId: 'connectorId', + data: { + state: 'state', + redirectUri: 'https://logto.dev', + code: '123456', + }, + }); + expect(updateUserById).not.toHaveBeenCalledWith(mockUser.id, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + identities: expect.anything(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastSignInAt: expect.anything(), + }); + }); }); describe('POST /session/sign-in/bind-social-related-user', () => { @@ -365,67 +422,4 @@ describe('session -> socialRoutes', () => { ); }); }); - - describe('POST /session/register/social', () => { - beforeEach(() => { - const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock; - mockGetLogtoConnectorById.mockResolvedValueOnce({ - metadata: { target: 'connectorTarget' }, - }); - }); - - it('register with social, assign result and redirect', async () => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'user1' } }, - }, - }); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'user1', - identities: { connectorTarget: { userId: 'user1', details: { id: 'user1' } } }, - }) - ); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), - expect.anything() - ); - }); - - it('throw error if no result can be found in interactionResults', async () => { - interactionDetails.mockResolvedValueOnce({}); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if result parsing fails', async () => { - interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: mockUser.id } } }); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error when user with identity exists', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - login: { accountId: 'user1' }, - socialUserInfo: { connectorId: 'connectorId', userInfo: { id: mockUser.id } }, - }, - }); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(response.statusCode).toEqual(400); - }); - }); }); diff --git a/packages/core/src/routes/session/social.ts b/packages/core/src/routes/session/social.ts index be6e86c0e..05f58c828 100644 --- a/packages/core/src/routes/session/social.ts +++ b/packages/core/src/routes/session/social.ts @@ -1,31 +1,32 @@ import { validateRedirectUrl } from '@logto/core-kit'; import { ConnectorType, userInfoSelectFields } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import pick from 'lodash.pick'; import type { Provider } from 'oidc-provider'; import { object, string, unknown } from 'zod'; -import { getLogtoConnectorById } from '@/connectors'; -import RequestError from '@/errors/RequestError'; -import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults, getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; import { findSocialRelatedUser, getUserInfoByAuthCode, getUserInfoFromInteractionResult, -} from '@/lib/social'; -import { generateUserId, insertUser } from '@/lib/user'; -import koaGuard from '@/middleware/koa-guard'; +} from '#src/lib/social.js'; +import { generateUserId, insertUser } from '#src/lib/user.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import { hasUserWithIdentity, findUserById, updateUserById, findUserByIdentity, -} from '@/queries/user'; -import assertThat from '@/utils/assert-that'; -import { maskUserInfo } from '@/utils/format'; +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; +import { maskUserInfo } from '#src/utils/format.js'; -import type { AnonymousRouter } from '../types'; -import { checkRequiredProfile, getRoutePrefix } from './utils'; +import type { AnonymousRouter } from '../types.js'; +import { checkRequiredProfile, getRoutePrefix } from './utils.js'; export const registerRoute = getRoutePrefix('register', 'social'); export const signInRoute = getRoutePrefix('sign-in', 'social'); @@ -45,7 +46,6 @@ export default function socialRoutes(router: T, provi const { connectorId, state, redirectUri } = ctx.guard.body; assertThat(state && redirectUri, 'session.insufficient_info'); const connector = await getLogtoConnectorById(connectorId); - assertThat(connector.dbEntry.enabled, 'connector.not_enabled'); assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type'); const redirectTo = await connector.getAuthorizationUri({ state, redirectUri }); ctx.body = { redirectTo }; @@ -70,12 +70,16 @@ export default function socialRoutes(router: T, provi ctx.log(type, { connectorId, data }); const { metadata: { target }, + dbEntry: { syncProfile }, } = await getLogtoConnectorById(connectorId); const userInfo = await getUserInfoByAuthCode(connectorId, data); ctx.log(type, { userInfo }); - if (!(await hasUserWithIdentity(target, userInfo.id))) { + const user = await findUserByIdentity(target, userInfo.id); + + // User with identity not found + if (!user) { await assignInteractionResults( ctx, provider, @@ -86,22 +90,30 @@ export default function socialRoutes(router: T, provi throw new RequestError( { - code: 'user.identity_not_exists', + code: 'user.identity_not_exist', status: 422, }, relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) } ); } - const user = await findUserByIdentity(target, userInfo.id); const { id, identities, isSuspended } = user; assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); ctx.log(type, { userId: id }); + const { name, avatar } = userInfo; + const profileUpdate = Object.fromEntries( + Object.entries({ + name: conditional(syncProfile && name), + avatar: conditional(syncProfile && avatar), + }).filter(([_key, value]) => value !== undefined) + ); + // Update social connector's user info await updateUserById(id, { identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } }, lastSignInAt: Date.now(), + ...profileUpdate, }); const signInExperience = await getSignInExperienceForApplication( @@ -178,7 +190,7 @@ export default function socialRoutes(router: T, provi const userInfo = await getUserInfoFromInteractionResult(connectorId, result); ctx.log(type, { userInfo }); - assertThat(!(await hasUserWithIdentity(target, userInfo.id)), 'user.identity_exists'); + assertThat(!(await hasUserWithIdentity(target, userInfo.id)), 'user.identity_already_in_use'); const id = await generateUserId(); const user = await insertUser({ diff --git a/packages/core/src/routes/session/utils.test.ts b/packages/core/src/routes/session/utils.test.ts index 6e6679d74..b9ea84d72 100644 --- a/packages/core/src/routes/session/utils.test.ts +++ b/packages/core/src/routes/session/utils.test.ts @@ -1,13 +1,13 @@ import type { User } from '@logto/schemas'; -import { UserRole, SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; -import { createMockContext } from '@shopify/jest-koa-mocks'; +import { UserRole, SignInIdentifier } from '@logto/schemas'; import type { Nullable } from '@silverhand/essentials'; import { Provider } from 'oidc-provider'; -import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__'; -import RequestError from '@/errors/RequestError'; +import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; -import { signInWithPassword } from './utils'; +import { checkRequiredProfile, signInWithPassword } from './utils.js'; const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const findUserById = jest.fn(async (): Promise => mockUser); @@ -17,11 +17,11 @@ const findDefaultSignInExperience = jest.fn(async () => ({ ...mockSignInExperience, signUp: { ...mockSignInExperience.signUp, - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], }, })); -jest.mock('@/queries/user', () => ({ +jest.mock('#src/queries/user.js', () => ({ findUserById: async () => findUserById(), findUserByIdentity: async () => ({ id: 'id', identities: {} }), findUserByPhone: async () => ({ id: 'id' }), @@ -40,11 +40,11 @@ jest.mock('@/queries/user', () => ({ }, })); -jest.mock('@/queries/sign-in-experience', () => ({ +jest.mock('#src/queries/sign-in-experience.js', () => ({ findDefaultSignInExperience: async () => findDefaultSignInExperience(), })); -jest.mock('@/lib/user', () => ({ +jest.mock('#src/lib/user.js', () => ({ async verifyUserPassword(user: Nullable, password: string) { if (!user) { throw new RequestError('session.invalid_credentials'); @@ -108,6 +108,188 @@ afterEach(() => { interactionResult.mockClear(); }); +describe('checkRequiredProfile', () => { + // eslint-disable-next-line @silverhand/fp/no-let + let mockDate: jest.SpyInstance; + const mockedExpiredAt = '2022-02-02'; + beforeEach(() => { + interactionDetails.mockResolvedValueOnce({ params: {} }); + // eslint-disable-next-line @silverhand/fp/no-mutation + mockDate = jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(mockedExpiredAt); + }); + + afterEach(() => { + mockDate.mockRestore(); + }); + + it("throw if password is required but the user's password is not set", async () => { + const user = { + ...mockUser, + passwordEncrypted: null, + passwordEncryptionMethod: null, + identities: {}, + }; + + const signInExperience = { + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + password: true, + }, + }; + + await expect( + checkRequiredProfile(createContext(), createProvider(), user, signInExperience) + ).rejects.toThrowError( + new RequestError({ code: 'user.password_required_in_profile', status: 422 }) + ); + + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) + ); + }); + + it("throw if the sign up identifier is ['username'] but the user's username is missing", async () => { + const user = { + ...mockUser, + username: null, + }; + const signInExperience = { + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + }; + + await expect( + checkRequiredProfile(createContext(), createProvider(), user, signInExperience) + ).rejects.toThrowError( + new RequestError({ code: 'user.username_required_in_profile', status: 422 }) + ); + + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) + ); + }); + + it("throw if the sign up identifier is ['email'] but the user's email is missing", async () => { + const user = { + ...mockUser, + primaryEmail: null, + }; + const signInExperience = { + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + }; + + await expect( + checkRequiredProfile(createContext(), createProvider(), user, signInExperience) + ).rejects.toThrowError( + new RequestError({ code: 'user.email_required_in_profile', status: 422 }) + ); + + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) + ); + }); + + it("throw if the sign up identifier is ['sms'] but the user's phone is missing", async () => { + const user = { + ...mockUser, + primaryPhone: null, + }; + const signInExperience = { + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + identifiers: [SignInIdentifier.Sms], + password: true, + verify: true, + }, + }; + + await expect( + checkRequiredProfile(createContext(), createProvider(), user, signInExperience) + ).rejects.toThrowError( + new RequestError({ code: 'user.phone_required_in_profile', status: 422 }) + ); + + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) + ); + }); + + it("throw if the sign up identifier is ['email', 'sms'] but the user's email and phone are missing", async () => { + const user = { + ...mockUser, + primaryEmail: null, + primaryPhone: null, + }; + const signInExperience = { + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], + password: true, + verify: true, + }, + }; + + await expect( + checkRequiredProfile(createContext(), createProvider(), user, signInExperience) + ).rejects.toThrowError( + new RequestError({ code: 'user.email_or_phone_required_in_profile', status: 422 }) + ); + + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) + ); + }); + + it.each([{ primaryEmail: null }, { primaryPhone: null }])( + "check successfully if the sign up identifier is ['email', 'sms'] and the user has an email or phone", + async (userProfile) => { + const user = { + ...mockUser, + ...userProfile, + }; + const signInExperience = { + ...mockSignInExperience, + signUp: { + ...mockSignInExperience.signUp, + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], + password: true, + verify: true, + }, + }; + + await expect( + checkRequiredProfile(createContext(), createProvider(), user, signInExperience) + ).resolves.not.toThrow(); + + expect(interactionResult).not.toBeCalled(); + } + ); +}); + describe('signInWithPassword()', () => { it('assign result', async () => { interactionDetails.mockResolvedValueOnce({ params: {} }); diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index a5da486be..232333ce6 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,30 +1,24 @@ -import type { - LogPayload, - LogType, - PasscodeType, - SignInExperience, - SignInIdentifier, - User, -} from '@logto/schemas'; -import { SignUpIdentifier, logTypeGuard } from '@logto/schemas'; +import type { LogPayload, LogType, PasscodeType, SignInExperience, User } from '@logto/schemas'; +import { SignInIdentifier, logTypeGuard } from '@logto/schemas'; import type { Nullable, Truthy } from '@silverhand/essentials'; +import { isSameArray } from '@silverhand/essentials'; import { addSeconds, isAfter, isValid } from 'date-fns'; import type { Context } from 'koa'; import type { Provider } from 'oidc-provider'; import type { ZodType } from 'zod'; import { z } from 'zod'; -import RequestError from '@/errors/RequestError'; -import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; -import { verifyUserPassword } from '@/lib/user'; -import type { LogContext } from '@/middleware/koa-log'; -import { hasUser, hasUserWithEmail, hasUserWithPhone, updateUserById } from '@/queries/user'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults, getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; +import { verifyUserPassword } from '#src/lib/user.js'; +import type { LogContext } from '#src/middleware/koa-log.js'; +import { updateUserById } from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; -import { continueSignInTimeout, verificationTimeout } from './consts'; -import type { Method, Operation, VerificationResult, VerificationStorage } from './types'; -import { continueSignInStorageGuard } from './types'; +import { continueSignInTimeout, verificationTimeout } from '../consts.js'; +import type { Method, Operation, VerificationResult, VerificationStorage } from './types.js'; +import { continueSignInStorageGuard } from './types.js'; export const getRoutePrefix = ( type: 'sign-in' | 'register' | 'forgot-password', @@ -177,54 +171,35 @@ export const checkRequiredProfile = async ( if (signUp.password && !isUserPasswordSet(user)) { await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.require_password', status: 422 }); + throw new RequestError({ code: 'user.password_required_in_profile', status: 422 }); } - if (signUp.identifier === SignUpIdentifier.Username && !username) { + if (isSameArray(signUp.identifiers, [SignInIdentifier.Username]) && !username) { await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.require_username', status: 422 }); + throw new RequestError({ code: 'user.username_required_in_profile', status: 422 }); } - if (signUp.identifier === SignUpIdentifier.Email && !primaryEmail) { + if (isSameArray(signUp.identifiers, [SignInIdentifier.Email]) && !primaryEmail) { await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.require_email', status: 422 }); + throw new RequestError({ code: 'user.email_required_in_profile', status: 422 }); } - if (signUp.identifier === SignUpIdentifier.Sms && !primaryPhone) { + if (isSameArray(signUp.identifiers, [SignInIdentifier.Sms]) && !primaryPhone) { await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.require_sms', status: 422 }); + throw new RequestError({ code: 'user.phone_required_in_profile', status: 422 }); } - if (signUp.identifier === SignUpIdentifier.EmailOrSms && !primaryEmail && !primaryPhone) { + if ( + isSameArray(signUp.identifiers, [SignInIdentifier.Email, SignInIdentifier.Sms]) && + !primaryEmail && + !primaryPhone + ) { await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.require_email_or_sms', status: 422 }); + throw new RequestError({ code: 'user.email_or_phone_required_in_profile', status: 422 }); } }; /* eslint-enable complexity */ -export const checkExistingSignUpIdentifiers = async ( - identifiers: { - username?: Nullable; - primaryEmail?: Nullable; - primaryPhone?: Nullable; - }, - excludeUserId: string -) => { - const { username, primaryEmail, primaryPhone } = identifiers; - - if (username && (await hasUser(username, excludeUserId))) { - throw new RequestError({ code: 'user.username_exists', status: 422 }); - } - - if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) { - throw new RequestError({ code: 'user.email_exists', status: 422 }); - } - - if (primaryPhone && (await hasUserWithPhone(primaryPhone, excludeUserId))) { - throw new RequestError({ code: 'user.sms_exists', status: 422 }); - } -}; - type SignInWithPasswordParameter = { identifier: SignInIdentifier; password: string; diff --git a/packages/core/src/routes/setting.test.ts b/packages/core/src/routes/setting.test.ts index 5ca217534..f3ccc1e61 100644 --- a/packages/core/src/routes/setting.test.ts +++ b/packages/core/src/routes/setting.test.ts @@ -1,20 +1,19 @@ import type { Setting, CreateSetting } from '@logto/schemas'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; -import { mockSetting } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockSetting } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import settingRoutes from './setting'; - -jest.mock('@/queries/setting', () => ({ - getSetting: jest.fn(async (): Promise => mockSetting), - updateSetting: jest.fn( - async (data: Partial): Promise => ({ - ...mockSetting, - ...data, - }) - ), +mockEsm('#src/queries/setting.js', () => ({ + getSetting: async (): Promise => mockSetting, + updateSetting: async (data: Partial): Promise => ({ + ...mockSetting, + ...data, + }), })); +const settingRoutes = await pickDefault(import('./setting.js')); + describe('settings routes', () => { const roleRequester = createRequester({ authedRoutes: settingRoutes }); diff --git a/packages/core/src/routes/setting.ts b/packages/core/src/routes/setting.ts index 0b87d92e0..b79c03982 100644 --- a/packages/core/src/routes/setting.ts +++ b/packages/core/src/routes/setting.ts @@ -1,9 +1,9 @@ import { Settings } from '@logto/schemas'; -import koaGuard from '@/middleware/koa-guard'; -import { getSetting, updateSetting } from '@/queries/setting'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { getSetting, updateSetting } from '#src/queries/setting.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; export default function settingRoutes(router: T) { router.get('/settings', async (ctx, next) => { diff --git a/packages/core/src/routes/sign-in-experience.branding.guard.test.ts b/packages/core/src/routes/sign-in-experience.branding.guard.test.ts index 07391ffc2..a0f2532ec 100644 --- a/packages/core/src/routes/sign-in-experience.branding.guard.test.ts +++ b/packages/core/src/routes/sign-in-experience.branding.guard.test.ts @@ -1,24 +1,24 @@ import type { CreateSignInExperience, SignInExperience } from '@logto/schemas'; import { BrandingStyle } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; -import { mockBranding, mockSignInExperience } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockBranding, mockSignInExperience } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import signInExperiencesRoutes from './sign-in-experience'; - -jest.mock('@/queries/sign-in-experience', () => ({ - updateDefaultSignInExperience: jest.fn( - async (data: Partial): Promise => ({ - ...mockSignInExperience, - ...data, - }) - ), +await mockEsmWithActual('#src/queries/sign-in-experience.js', () => ({ + updateDefaultSignInExperience: async ( + data: Partial + ): Promise => ({ + ...mockSignInExperience, + ...data, + }), })); -jest.mock('@/connectors', () => ({ - getLogtoConnectors: jest.fn(async () => []), +mockEsm('#src/connectors.js', () => ({ + getLogtoConnectors: async () => [], })); +const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js')); const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes }); const expectPatchResponseStatus = async ( diff --git a/packages/core/src/routes/sign-in-experience.color.guard.test.ts b/packages/core/src/routes/sign-in-experience.color.guard.test.ts index 00c9f0d9b..d6295c2ed 100644 --- a/packages/core/src/routes/sign-in-experience.color.guard.test.ts +++ b/packages/core/src/routes/sign-in-experience.color.guard.test.ts @@ -1,23 +1,23 @@ import type { CreateSignInExperience, SignInExperience } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; -import { mockColor, mockSignInExperience } from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +import { mockColor, mockSignInExperience } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import signInExperiencesRoutes from './sign-in-experience'; - -jest.mock('@/queries/sign-in-experience', () => ({ - updateDefaultSignInExperience: jest.fn( - async (data: Partial): Promise => ({ - ...mockSignInExperience, - ...data, - }) - ), +await mockEsmWithActual('#src/queries/sign-in-experience.js', () => ({ + updateDefaultSignInExperience: async ( + data: Partial + ): Promise => ({ + ...mockSignInExperience, + ...data, + }), })); -jest.mock('@/connectors', () => ({ - getLogtoConnectors: jest.fn(async () => []), +mockEsm('#src/connectors.js', () => ({ + getLogtoConnectors: async () => [], })); +const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js')); const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes }); const expectPatchResponseStatus = async ( diff --git a/packages/core/src/routes/sign-in-experience.guard.test.ts b/packages/core/src/routes/sign-in-experience.guard.test.ts index 63230c7d3..063ea6ce0 100644 --- a/packages/core/src/routes/sign-in-experience.guard.test.ts +++ b/packages/core/src/routes/sign-in-experience.guard.test.ts @@ -1,4 +1,5 @@ -import type { CreateSignInExperience, LanguageInfo, SignInExperience } from '@logto/schemas'; +import type { CreateSignInExperience, SignInExperience } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import { mockAliyunDmConnector, @@ -9,12 +10,11 @@ import { mockLanguageInfo, mockSignInExperience, mockTermsOfUse, -} from '@/__mocks__'; -import { createRequester } from '@/utils/test-utils'; +} from '#src/__mocks__/index.js'; -import signInExperiencesRoutes from './sign-in-experience'; +const { jest } = import.meta; -jest.mock('@/connectors', () => ({ +mockEsm('#src/connectors.js', () => ({ getLogtoConnectors: jest.fn(async () => [ mockAliyunDmConnector, mockAliyunSmsConnector, @@ -24,23 +24,21 @@ jest.mock('@/connectors', () => ({ ]), })); -// eslint-disable-next-line @typescript-eslint/no-empty-function -const validateLanguageInfo = jest.fn(async (languageInfo: LanguageInfo): Promise => {}); - -jest.mock('@/lib/sign-in-experience', () => ({ - ...jest.requireActual('@/lib/sign-in-experience'), - validateLanguageInfo: async (languageInfo: LanguageInfo) => validateLanguageInfo(languageInfo), +const { validateLanguageInfo } = await mockEsmWithActual('#src/lib/sign-in-experience.js', () => ({ + validateLanguageInfo: jest.fn(), })); -jest.mock('@/queries/sign-in-experience', () => ({ - updateDefaultSignInExperience: jest.fn( - async (data: Partial): Promise => ({ - ...mockSignInExperience, - ...data, - }) - ), +await mockEsmWithActual('#src/queries/sign-in-experience.js', () => ({ + updateDefaultSignInExperience: async ( + data: Partial + ): Promise => ({ + ...mockSignInExperience, + ...data, + }), })); +const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js')); +const { createRequester } = await import('#src/utils/test-utils.js'); const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes }); const expectPatchResponseStatus = async ( diff --git a/packages/core/src/routes/sign-in-experience.test.ts b/packages/core/src/routes/sign-in-experience.test.ts index 7999d9d08..9565d71c8 100644 --- a/packages/core/src/routes/sign-in-experience.test.ts +++ b/packages/core/src/routes/sign-in-experience.test.ts @@ -1,4 +1,5 @@ import type { SignInExperience, CreateSignInExperience, TermsOfUse } from '@logto/schemas'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import { mockFacebookConnector, @@ -12,13 +13,24 @@ import { mockSignIn, mockLanguageInfo, mockAliyunSmsConnector, -} from '@/__mocks__'; -import * as signInExpLib from '@/lib/sign-in-experience'; -import * as signInLib from '@/lib/sign-in-experience/sign-in'; -import * as signUpLib from '@/lib/sign-in-experience/sign-up'; -import { createRequester } from '@/utils/test-utils'; +} from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; -import signInExperiencesRoutes from './sign-in-experience'; +const { jest } = import.meta; + +const { + validateBranding, + validateLanguageInfo, + validateTermsOfUse, + validateSignIn, + validateSignUp, +} = await mockEsmWithActual('#src/lib/sign-in-experience/index.js', () => ({ + validateBranding: jest.fn(), + validateLanguageInfo: jest.fn(), + validateTermsOfUse: jest.fn(), + validateSignIn: jest.fn(), + validateSignUp: jest.fn(), +})); const logtoConnectors = [ mockFacebookConnector, @@ -28,33 +40,27 @@ const logtoConnectors = [ mockAliyunSmsConnector, ]; -const getLogtoConnectors = jest.fn(async () => logtoConnectors); - -jest.mock('@/connectors', () => { - return { - ...jest.requireActual('@/connectors'), - getLogtoConnectors: jest.fn(async () => getLogtoConnectors()), - }; -}); - -const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience); - -jest.mock('@/queries/sign-in-experience', () => ({ - findDefaultSignInExperience: jest.fn(async () => findDefaultSignInExperience()), - updateDefaultSignInExperience: jest.fn( - async (data: Partial): Promise => ({ - ...mockSignInExperience, - ...data, - }) - ), +await mockEsmWithActual('#src/connectors.js', () => ({ + getLogtoConnectors: async () => logtoConnectors, })); -const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes }); +const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({ + findDefaultSignInExperience: jest.fn(async () => mockSignInExperience), + updateDefaultSignInExperience: async ( + data: Partial + ): Promise => ({ + ...mockSignInExperience, + ...data, + }), +})); -jest.mock('@/queries/custom-phrase', () => ({ +mockEsm('#src/queries/custom-phrase.js', () => ({ findAllCustomLanguageTags: async () => [], })); +const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js')); +const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes }); + describe('GET /sign-in-exp', () => { afterAll(() => { jest.clearAllMocks(); @@ -94,7 +100,7 @@ describe('PATCH /sign-in-exp', () => { status: 200, body: { ...mockSignInExperience, - socialSignInConnectorTargets: ['github', 'facebook'], + socialSignInConnectorTargets: ['github', 'facebook', 'google'], }, }); }); @@ -103,12 +109,6 @@ describe('PATCH /sign-in-exp', () => { const termsOfUse: TermsOfUse = { enabled: false }; const socialSignInConnectorTargets = ['github', 'facebook', 'wechat']; - const validateBranding = jest.spyOn(signInExpLib, 'validateBranding'); - const validateLanguageInfo = jest.spyOn(signInExpLib, 'validateLanguageInfo'); - const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse'); - const validateSignIn = jest.spyOn(signInLib, 'validateSignIn'); - const validateSignUp = jest.spyOn(signUpLib, 'validateSignUp'); - const response = await signInExperienceRequester.patch('/sign-in-exp').send({ color: mockColor, branding: mockBranding, @@ -118,18 +118,12 @@ describe('PATCH /sign-in-exp', () => { signUp: mockSignUp, signIn: mockSignIn, }); - const connectors = [ - mockFacebookConnector, - mockGithubConnector, - mockWechatConnector, - mockAliyunSmsConnector, - ]; expect(validateBranding).toHaveBeenCalledWith(mockBranding); expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo); expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse); - expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, connectors); - expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, connectors); + expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, logtoConnectors); + expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, logtoConnectors); expect(response).toMatchObject({ status: 200, diff --git a/packages/core/src/routes/sign-in-experience.ts b/packages/core/src/routes/sign-in-experience.ts index c26accabb..db6fb71da 100644 --- a/packages/core/src/routes/sign-in-experience.ts +++ b/packages/core/src/routes/sign-in-experience.ts @@ -1,20 +1,20 @@ import { ConnectorType, SignInExperiences } from '@logto/schemas'; -import { getLogtoConnectors } from '@/connectors'; +import { getLogtoConnectors } from '#src/connectors/index.js'; import { validateBranding, validateLanguageInfo, validateTermsOfUse, validateSignUp, validateSignIn, -} from '@/lib/sign-in-experience'; -import koaGuard from '@/middleware/koa-guard'; +} from '#src/lib/sign-in-experience/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import { findDefaultSignInExperience, updateDefaultSignInExperience, -} from '@/queries/sign-in-experience'; +} from '#src/queries/sign-in-experience.js'; -import type { AuthedRouter } from './types'; +import type { AuthedRouter } from './types.js'; export default function signInExperiencesRoutes(router: T) { /** @@ -50,25 +50,24 @@ export default function signInExperiencesRoutes(router: } const connectors = await getLogtoConnectors(); - const enabledConnectors = connectors.filter(({ dbEntry: { enabled } }) => enabled); // Remove unavailable connectors const filteredSocialSignInConnectorTargets = socialSignInConnectorTargets?.filter((target) => - enabledConnectors.some( + connectors.some( (connector) => connector.metadata.target === target && connector.type === ConnectorType.Social ) ); if (signUp) { - validateSignUp(signUp, enabledConnectors); + validateSignUp(signUp, connectors); } if (signIn && signUp) { - validateSignIn(signIn, signUp, enabledConnectors); + validateSignIn(signIn, signUp, connectors); } else if (signIn) { const signInExperience = await findDefaultSignInExperience(); - validateSignIn(signIn, signInExperience.signUp, enabledConnectors); + validateSignIn(signIn, signInExperience.signUp, connectors); } ctx.body = await updateDefaultSignInExperience( filteredSocialSignInConnectorTargets diff --git a/packages/core/src/routes/status.test.ts b/packages/core/src/routes/status.test.ts index 735416097..43f39912c 100644 --- a/packages/core/src/routes/status.test.ts +++ b/packages/core/src/routes/status.test.ts @@ -1,6 +1,6 @@ -import { createRequester } from '@/utils/test-utils'; +import { createRequester } from '#src/utils/test-utils.js'; -import statusRoutes from './status'; +import statusRoutes from './status.js'; describe('status router', () => { const requester = createRequester({ anonymousRoutes: statusRoutes }); diff --git a/packages/core/src/routes/status.ts b/packages/core/src/routes/status.ts index 1dba71d37..916c002eb 100644 --- a/packages/core/src/routes/status.ts +++ b/packages/core/src/routes/status.ts @@ -1,6 +1,6 @@ -import koaGuard from '@/middleware/koa-guard'; +import koaGuard from '#src/middleware/koa-guard.js'; -import type { AnonymousRouter } from './types'; +import type { AnonymousRouter } from './types.js'; export default function statusRoutes(router: T) { router.get('/status', koaGuard({ status: 204 }), async (ctx, next) => { diff --git a/packages/core/src/routes/swagger.test.ts b/packages/core/src/routes/swagger.test.ts index 03928e532..4fb2103af 100644 --- a/packages/core/src/routes/swagger.test.ts +++ b/packages/core/src/routes/swagger.test.ts @@ -1,19 +1,25 @@ -import { load } from 'js-yaml'; +import { mockEsm } from '@logto/shared/esm'; import Koa from 'koa'; import Router from 'koa-router'; import request from 'supertest'; import { number, object, string } from 'zod'; -import koaGuard from '@/middleware/koa-guard'; -import koaPagination from '@/middleware/koa-pagination'; -import type { AnonymousRouter } from '@/routes/types'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import type { AnonymousRouter } from '#src/routes/types.js'; -import swaggerRoutes, { defaultResponses, paginationParameters } from './swagger'; +const { jest } = import.meta; -jest.mock('js-yaml', () => ({ +const { load } = mockEsm('js-yaml', () => ({ load: jest.fn().mockReturnValue({ paths: {} }), })); +const { + default: swaggerRoutes, + defaultResponses, + paginationParameters, +} = await import('./swagger.js'); + export const createSwaggerRequest = ( allRouters: Router[], swaggerRouter: AnonymousRouter = new Router() @@ -221,7 +227,7 @@ describe('GET /swagger.json', () => { describe('should use correct responses', () => { it('should use "defaultResponses" if there is no custom "responses" from the additional swagger', async () => { - (load as jest.Mock).mockReturnValueOnce({ + load.mockReturnValueOnce({ paths: { '/api/mock': { delete: {} } }, }); @@ -235,7 +241,7 @@ describe('GET /swagger.json', () => { }); it('should use custom "responses" from the additional swagger if it exists', async () => { - (load as jest.Mock).mockReturnValueOnce({ + load.mockReturnValueOnce({ paths: { '/api/mock': { get: { diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger.ts index 18e6565c9..e72ab66a8 100644 --- a/packages/core/src/routes/swagger.ts +++ b/packages/core/src/routes/swagger.ts @@ -7,13 +7,13 @@ import type Router from 'koa-router'; import type { OpenAPIV3 } from 'openapi-types'; import { ZodObject, ZodOptional } from 'zod'; -import type { WithGuardConfig } from '@/middleware/koa-guard'; -import { isGuardMiddleware } from '@/middleware/koa-guard'; -import { fallbackDefaultPageSize, isPaginationMiddleware } from '@/middleware/koa-pagination'; -import assertThat from '@/utils/assert-that'; -import { translationSchemas, zodTypeToSwagger } from '@/utils/zod'; +import type { WithGuardConfig } from '#src/middleware/koa-guard.js'; +import { isGuardMiddleware } from '#src/middleware/koa-guard.js'; +import { fallbackDefaultPageSize, isPaginationMiddleware } from '#src/middleware/koa-pagination.js'; +import assertThat from '#src/utils/assert-that.js'; +import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js'; -import type { AnonymousRouter } from './types'; +import type { AnonymousRouter } from './types.js'; type RouteObject = { path: string; diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index f1b3330bc..e45c93cd3 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -1,8 +1,8 @@ import type Router from 'koa-router'; -import type { WithAuthContext } from '@/middleware/koa-auth'; -import type { WithI18nContext } from '@/middleware/koa-i18next'; -import type { WithLogContext } from '@/middleware/koa-log'; +import type { WithAuthContext } from '#src/middleware/koa-auth.js'; +import type { WithI18nContext } from '#src/middleware/koa-i18next.js'; +import type { WithLogContext } from '#src/middleware/koa-log.js'; export type AnonymousRouter = Router; diff --git a/packages/core/src/routes/well-known.test.ts b/packages/core/src/routes/well-known.test.ts index 95c90d54a..33c66db9d 100644 --- a/packages/core/src/routes/well-known.test.ts +++ b/packages/core/src/routes/well-known.test.ts @@ -1,6 +1,9 @@ import { SignInMode } from '@logto/schemas'; -import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas/lib/seeds'; -import { Provider } from 'oidc-provider'; +import { + adminConsoleApplicationId, + adminConsoleSignInExperience, +} from '@logto/schemas/lib/seeds/index.js'; +import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import { mockAliyunDmConnector, @@ -11,51 +14,49 @@ import { mockSignInExperience, mockWechatConnector, mockWechatNativeConnector, -} from '@/__mocks__'; -import * as signInExperienceQueries from '@/queries/sign-in-experience'; -import wellKnownRoutes from '@/routes/well-known'; -import { createRequester } from '@/utils/test-utils'; +} from '#src/__mocks__/index.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { createRequester } from '#src/utils/test-utils.js'; -const getLogtoConnectors = jest.fn(async () => [ - mockAliyunDmConnector, - mockAliyunSmsConnector, - mockFacebookConnector, - mockGithubConnector, - mockGoogleConnector, - mockWechatConnector, - mockWechatNativeConnector, -]); - -jest.mock('@/connectors', () => ({ - getLogtoConnectors: async () => getLogtoConnectors(), +const { jest } = import.meta; +await mockEsmWithActual('i18next', () => ({ + default: { + t: (key: string) => key, + }, })); -jest.mock('@/queries/user', () => ({ +const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({ + updateDefaultSignInExperience: jest.fn(), + findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience), +})); + +await mockEsmWithActual('#src/queries/user.js', () => ({ hasActiveUsers: jest.fn().mockResolvedValue(true), })); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({ - params: {}, +mockEsm('#src/connectors.js', () => ({ + getLogtoConnectors: jest.fn(async () => [ + mockAliyunDmConnector, + mockAliyunSmsConnector, + mockFacebookConnector, + mockGithubConnector, + mockGoogleConnector, + mockWechatConnector, + mockWechatNativeConnector, + ]), })); -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - })), -})); - -jest.mock('i18next', () => ({ - t: (key: string) => key, -})); +const wellKnownRoutes = await pickDefault(import('#src/routes/well-known.js')); describe('GET /.well-known/sign-in-exp', () => { afterEach(() => { jest.clearAllMocks(); }); + const provider = createMockProvider(); const sessionRequest = createRequester({ anonymousRoutes: wellKnownRoutes, - provider: new Provider(''), + provider, middlewares: [ async (ctx, next) => { ctx.addLogContext = jest.fn(); @@ -66,13 +67,9 @@ describe('GET /.well-known/sign-in-exp', () => { ], }); - const signInExperienceQuerySpyOn = jest - .spyOn(signInExperienceQueries, 'findDefaultSignInExperience') - .mockResolvedValue(mockSignInExperience); - it('should return github and facebook connector instances', async () => { const response = await sessionRequest.get('/.well-known/sign-in-exp'); - expect(signInExperienceQuerySpyOn).toHaveBeenCalledTimes(1); + expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1); expect(response.status).toEqual(200); expect(response.body).toMatchObject({ ...mockSignInExperience, @@ -98,7 +95,10 @@ describe('GET /.well-known/sign-in-exp', () => { }); it('should return admin console settings', async () => { - interactionDetails.mockResolvedValue({ params: { client_id: adminConsoleApplicationId } }); + jest + .spyOn(provider, 'interactionDetails') + // @ts-expect-error for testing + .mockResolvedValue({ params: { client_id: adminConsoleApplicationId } }); const response = await sessionRequest.get('/.well-known/sign-in-exp'); expect(response.status).toEqual(200); diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index fd8ddc981..65314a50d 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -1,14 +1,14 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorType } from '@logto/connector-kit'; -import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; +import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds/index.js'; import etag from 'etag'; import type { Provider } from 'oidc-provider'; -import { getLogtoConnectors } from '@/connectors'; -import { getApplicationIdFromInteraction } from '@/lib/session'; -import { getSignInExperienceForApplication } from '@/lib/sign-in-experience'; +import { getLogtoConnectors } from '#src/connectors/index.js'; +import { getApplicationIdFromInteraction } from '#src/lib/session.js'; +import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js'; -import type { AnonymousRouter } from './types'; +import type { AnonymousRouter } from './types.js'; export default function wellKnownRoutes(router: T, provider: Provider) { router.get( @@ -22,12 +22,8 @@ export default function wellKnownRoutes(router: T, pr ]); const forgotPassword = { - sms: logtoConnectors.some( - ({ type, dbEntry: { enabled } }) => type === ConnectorType.Sms && enabled - ), - email: logtoConnectors.some( - ({ type, dbEntry: { enabled } }) => type === ConnectorType.Email && enabled - ), + sms: logtoConnectors.some(({ type }) => type === ConnectorType.Sms), + email: logtoConnectors.some(({ type }) => type === ConnectorType.Email), }; const socialConnectors = @@ -37,8 +33,7 @@ export default function wellKnownRoutes(router: T, pr Array >((previous, connectorTarget) => { const connectors = logtoConnectors.filter( - ({ metadata: { target }, dbEntry: { enabled } }) => - target === connectorTarget && enabled + ({ metadata: { target } }) => target === connectorTarget ); return [ diff --git a/packages/core/src/test-utils/jest-koa-mocks/LICENSE b/packages/core/src/test-utils/jest-koa-mocks/LICENSE new file mode 100644 index 000000000..9685c7522 --- /dev/null +++ b/packages/core/src/test-utils/jest-koa-mocks/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2018-present Shopify + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/core/src/test-utils/jest-koa-mocks/README.md b/packages/core/src/test-utils/jest-koa-mocks/README.md new file mode 100644 index 000000000..a46306564 --- /dev/null +++ b/packages/core/src/test-utils/jest-koa-mocks/README.md @@ -0,0 +1,3 @@ +# jest-koa-mocks + +Updated from https://github.com/Shopify/quilt/tree/main/packages/jest-koa-mocks since ESM tests need explicit `const { jest } = import.meta;` to fetch the global jest object. diff --git a/packages/core/src/test-utils/jest-koa-mocks/create-mock-context.ts b/packages/core/src/test-utils/jest-koa-mocks/create-mock-context.ts new file mode 100644 index 000000000..cd8b0a93d --- /dev/null +++ b/packages/core/src/test-utils/jest-koa-mocks/create-mock-context.ts @@ -0,0 +1,119 @@ +/* eslint-disable unicorn/no-abusive-eslint-disable */ +/* eslint-disable */ +import stream from 'stream'; +import { URL } from 'url'; + +import type { Context } from 'koa'; +import Koa from 'koa'; +import type { RequestMethod } from 'node-mocks-http'; +import httpMocks from 'node-mocks-http'; + +import type { MockCookies } from './create-mock-cookies.js'; +import createMockCookies from './create-mock-cookies.js'; + +const { jest } = import.meta; + +export type Dictionary = Record; + +export type MockContext = { + cookies: MockCookies; + request: Context['request'] & { + body?: any; + rawBody?: string; + session?: any; + }; +} & Context; + +export type Options, RequestBody = undefined> = { + url?: string; + method?: RequestMethod; + statusCode?: number; + session?: Dictionary; + headers?: Dictionary; + cookies?: Dictionary; + state?: Dictionary; + encrypted?: boolean; + host?: string; + requestBody?: RequestBody; + rawBody?: string; + throw?: Function; + redirect?: Function; + customProperties?: CustomProperties; +}; + +export default function createContext< + CustomProperties extends Record, + RequestBody = undefined +>(options: Options = {}) { + const app = new Koa(); + + const { + cookies, + method, + statusCode, + session, + requestBody, + rawBody = '', + url = '', + host = 'test.com', + encrypted = false, + throw: throwFunction = jest.fn(), + redirect = jest.fn(), + headers = {}, + state = {}, + customProperties = {}, + } = options; + + const extensions = { + ...customProperties, + throw: throwFunction, + session, + redirect, + state, + }; + + const protocolFallback = encrypted ? 'https' : 'http'; + const urlObject = new URL(url, `${protocolFallback}://${host}`); + + const request = httpMocks.createRequest({ + url: urlObject.toString(), + method, + statusCode, + session, + headers: { + // Koa determines protocol based on the `Host` header. + Host: urlObject.host, + ...headers, + }, + }); + + // Some functions we call in the implementations will perform checks for `req.encrypted`, which delegates to the socket. + // MockRequest doesn't set a fake socket itself, so we create one here. + request.socket = new stream.Duplex() as any; + Object.defineProperty(request.socket, 'encrypted', { + writable: false, + value: urlObject.protocol === 'https:', + }); + + const res = httpMocks.createResponse(); + + // Koa sets a default status code of 404, not the node default of 200 + // https://github.com/koajs/koa/blob/master/docs/api/response.md#responsestatus + res.statusCode = 404; + + // This is to get around an odd behavior in the `cookies` library, where if `res.set` is defined, it will use an internal + // node function to set headers, which results in them being set in the wrong place. + + res.set = undefined as any; + + const context = app.createContext(request, res) as MockContext & CustomProperties; + Object.assign(context, extensions); + context.cookies = createMockCookies(cookies); + + // Ctx.request.body is a common enough custom property for middleware to add that it's handy to just support it by default + context.request.body = requestBody; + context.request.rawBody = rawBody; + + return context as Context; +} +/* eslint-enable */ diff --git a/packages/core/src/test-utils/jest-koa-mocks/create-mock-cookies.ts b/packages/core/src/test-utils/jest-koa-mocks/create-mock-cookies.ts new file mode 100644 index 000000000..d4c9b185c --- /dev/null +++ b/packages/core/src/test-utils/jest-koa-mocks/create-mock-cookies.ts @@ -0,0 +1,37 @@ +/* eslint-disable unicorn/no-abusive-eslint-disable */ +/* eslint-disable */ +import type { Context } from 'koa'; + +const { jest } = import.meta; + +export type Cookies = Context['cookies']; + +export type Dictionary = Record; + +export type MockCookies = { + requestStore: Map; + responseStore: Map; +} & Cookies; + +export default function createMockCookies( + cookies: Record = {}, + secure = true +): MockCookies { + const cookieEntries = Object.keys(cookies).map((key) => [key, cookies[key]] as [string, string]); + + const requestStore = new Map(cookieEntries); + const responseStore = new Map(cookieEntries); + + return { + set: jest.fn((key, value) => { + return responseStore.set(key, value); + }), + get: jest.fn((key) => { + return requestStore.get(key); + }), + requestStore, + responseStore, + secure, + } as any; +} +/* eslint-enable */ diff --git a/packages/core/src/test-utils/oidc-provider.ts b/packages/core/src/test-utils/oidc-provider.ts new file mode 100644 index 000000000..4893d602b --- /dev/null +++ b/packages/core/src/test-utils/oidc-provider.ts @@ -0,0 +1,24 @@ +import { Provider } from 'oidc-provider'; + +const { jest } = import.meta; + +export const createMockProvider = (interactionDetails?: jest.Mock): Provider => { + const originalWarn = console.warn; + const warn = jest.spyOn(console, 'warn').mockImplementation((...args) => { + // Disable while creating. Too many warnings. + if (typeof args[0] === 'string' && args[0].includes('oidc-provider')) { + return; + } + + originalWarn(...args); + }); + const provider = new Provider('https://logto.test'); + + warn.mockRestore(); + jest.spyOn(provider, 'interactionDetails').mockImplementation( + // @ts-expect-error for testing + interactionDetails ?? (async () => ({ params: {}, jti: 'jti', client_id: 'mockApplicationId' })) + ); + + return provider; +}; diff --git a/packages/core/src/utils/assert-that.test.ts b/packages/core/src/utils/assert-that.test.ts index 8c0a86782..c77c35756 100644 --- a/packages/core/src/utils/assert-that.test.ts +++ b/packages/core/src/utils/assert-that.test.ts @@ -1,6 +1,6 @@ -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; -import assertThat from './assert-that'; +import assertThat from './assert-that.js'; describe('assertThat util', () => { it('assert to be truthy', () => { diff --git a/packages/core/src/utils/assert-that.ts b/packages/core/src/utils/assert-that.ts index a1273aa71..3d819bac3 100644 --- a/packages/core/src/utils/assert-that.ts +++ b/packages/core/src/utils/assert-that.ts @@ -1,7 +1,7 @@ import type { LogtoErrorCode } from '@logto/phrases'; import { assert } from '@silverhand/essentials'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; export type AssertThatFunction = ( value: unknown, diff --git a/packages/core/src/utils/format.test.ts b/packages/core/src/utils/format.test.ts index 387cf1514..ef27f8bd3 100644 --- a/packages/core/src/utils/format.test.ts +++ b/packages/core/src/utils/format.test.ts @@ -1,4 +1,4 @@ -import { maskUserInfo } from './format'; +import { maskUserInfo } from './format.js'; describe('maskUserInfo', () => { it('phone', () => { diff --git a/packages/core/src/utils/oidc-provider-event-listener.test.ts b/packages/core/src/utils/oidc-provider-event-listener.test.ts index 2076ed8b9..bea89da3c 100644 --- a/packages/core/src/utils/oidc-provider-event-listener.test.ts +++ b/packages/core/src/utils/oidc-provider-event-listener.test.ts @@ -6,8 +6,10 @@ import { grantErrorListener, grantRevokedListener, grantSuccessListener, -} from '@/utils/oidc-provider-event-listener'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; +} from '#src/utils/oidc-provider-event-listener.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; const userId = 'userIdValue'; const sessionId = 'sessionIdValue'; @@ -15,30 +17,18 @@ const applicationId = 'applicationIdValue'; const addLogContext = jest.fn(); const log = jest.fn(); -const addListener = jest.fn(); - -jest.mock('oidc-provider', () => ({ Provider: jest.fn(() => ({ addListener })) })); describe('addOidcEventListeners', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should add grantSuccessListener', () => { - const provider = new Provider(''); + it('should add proper listeners', () => { + const provider = new Provider('https://logto.test'); + const addListener = jest.spyOn(provider, 'addListener'); addOidcEventListeners(provider); expect(addListener).toHaveBeenCalledWith('grant.success', grantSuccessListener); - }); - - it('should add grantErrorListener', () => { - const provider = new Provider(''); - addOidcEventListeners(provider); expect(addListener).toHaveBeenCalledWith('grant.error', grantErrorListener); - }); - - it('should add grantRevokedListener', () => { - const provider = new Provider(''); - addOidcEventListeners(provider); expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevokedListener); }); }); diff --git a/packages/core/src/utils/oidc-provider-event-listener.ts b/packages/core/src/utils/oidc-provider-event-listener.ts index 862fe9c60..86a170469 100644 --- a/packages/core/src/utils/oidc-provider-event-listener.ts +++ b/packages/core/src/utils/oidc-provider-event-listener.ts @@ -2,7 +2,7 @@ import { GrantType, TokenType, LogResult } from '@logto/schemas'; import { notFalsy } from '@silverhand/essentials'; import type { errors, KoaContextWithOIDC, Provider } from 'oidc-provider'; -import type { WithLogContext } from '@/middleware/koa-log'; +import type { WithLogContext } from '#src/middleware/koa-log.js'; export const addOidcEventListeners = (provider: Provider) => { /** diff --git a/packages/core/src/utils/pagination.test.ts b/packages/core/src/utils/pagination.test.ts index fdbf86989..1ad9d4bd2 100644 --- a/packages/core/src/utils/pagination.test.ts +++ b/packages/core/src/utils/pagination.test.ts @@ -1,4 +1,4 @@ -import { buildLink } from './pagination'; +import { buildLink } from './pagination.js'; const request = { origin: 'https://logto.dev', diff --git a/packages/core/src/utils/password.ts b/packages/core/src/utils/password.ts index b7eafc726..8d35b7538 100644 --- a/packages/core/src/utils/password.ts +++ b/packages/core/src/utils/password.ts @@ -3,8 +3,8 @@ import crypto from 'crypto'; import { UsersPasswordEncryptionMethod } from '@logto/schemas'; import { argon2i } from 'hash-wasm'; -import RequestError from '@/errors/RequestError'; -import assertThat from '@/utils/assert-that'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; export const encryptPassword = async ( password: string, diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index 6a5be9040..cf0a1dd49 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -1,5 +1,3 @@ -import type { Options } from '@shopify/jest-koa-mocks'; -import { createMockContext } from '@shopify/jest-koa-mocks'; import type { MiddlewareType, Context, Middleware } from 'koa'; import Koa from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -7,10 +5,12 @@ import Router from 'koa-router'; import type { Provider } from 'oidc-provider'; import type { QueryResult, QueryResultRow } from 'slonik'; import { createMockPool, createMockQueryResult } from 'slonik'; -import type { PrimitiveValueExpression } from 'slonik/dist/src/types.d'; +import type { PrimitiveValueExpression } from 'slonik/dist/src/types.js'; import request from 'supertest'; -import type { AuthedRouter, AnonymousRouter } from '@/routes/types'; +import type { AuthedRouter, AnonymousRouter } from '#src/routes/types.js'; +import type { Options } from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; +import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; /** * Slonik Query Mock Utils diff --git a/packages/core/src/utils/translation.test.ts b/packages/core/src/utils/translation.test.ts index d9bb27671..9fe10346e 100644 --- a/packages/core/src/utils/translation.test.ts +++ b/packages/core/src/utils/translation.test.ts @@ -1,7 +1,7 @@ -import en from '@logto/phrases-ui/lib/locales/en'; -import fr from '@logto/phrases-ui/lib/locales/fr'; +import en from '@logto/phrases-ui/lib/locales/en.js'; +import fr from '@logto/phrases-ui/lib/locales/fr.js'; -import { isStrictlyPartial } from '@/utils/translation'; +import { isStrictlyPartial } from '#src/utils/translation.js'; const customizedFrTranslation = { secondary: { diff --git a/packages/core/src/utils/zod.test.ts b/packages/core/src/utils/zod.test.ts index bc6cb09df..5b04acc0f 100644 --- a/packages/core/src/utils/zod.test.ts +++ b/packages/core/src/utils/zod.test.ts @@ -2,10 +2,10 @@ import { languages, languageTagGuard } from '@logto/language-kit'; import { ApplicationType, arbitraryObjectGuard, translationGuard } from '@logto/schemas'; import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; -import type { ZodStringCheck } from './zod'; -import { zodTypeToSwagger } from './zod'; +import type { ZodStringCheck } from './zod.js'; +import { zodTypeToSwagger } from './zod.js'; describe('zodTypeToSwagger', () => { it('arbitrary object guard', () => { diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index 3d3b36c55..957d66ca0 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -20,7 +20,7 @@ import { ZodUnknown, } from 'zod'; -import RequestError from '@/errors/RequestError'; +import RequestError from '#src/errors/RequestError/index.js'; export const translationSchemas: Record = { TranslationObject: { diff --git a/packages/core/tsconfig.base.json b/packages/core/tsconfig.base.json index 2ee2d67f8..247fd3037 100644 --- a/packages/core/tsconfig.base.json +++ b/packages/core/tsconfig.base.json @@ -1,10 +1,13 @@ { "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { + "moduleResolution": "nodenext", + "module": "esnext", + "declaration": false, "outDir": "build", "baseUrl": ".", "paths": { - "@/*": [ + "#src/*": [ "src/*" ] } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 64517c9bb..2796b3926 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -8,7 +8,6 @@ ] }, "include": [ - "src", - "jest.*.ts" + "src" ] } diff --git a/packages/create/package.json b/packages/create/package.json index 36f9b42cd..a2d1bd604 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -3,6 +3,7 @@ "version": "1.0.0-beta.16", "author": "Silverhand Inc. ", "license": "MPL-2.0", + "type": "module", "publishConfig": { "access": "public" }, @@ -14,6 +15,6 @@ "node": "^16.13.0 || ^18.12.0" }, "dependencies": { - "@logto/cli": "workspace:^" + "@logto/cli": "workspace:*" } } diff --git a/packages/demo-app/package.json b/packages/demo-app/package.json index 924bb9c50..58104384a 100644 --- a/packages/demo-app/package.json +++ b/packages/demo-app/package.json @@ -6,7 +6,6 @@ "license": "MPL-2.0", "private": true, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", "precommit": "lint-staged", "start": "parcel src/index.html", "dev": "cross-env PORT=5003 parcel src/index.html --public-url /demo-app --no-cache --hmr-port 6003", @@ -17,13 +16,13 @@ "stylelint": "stylelint \"src/**/*.scss\"" }, "devDependencies": { - "@logto/core-kit": "1.0.0-beta.20", - "@logto/language-kit": "1.0.0-beta.20", - "@logto/phrases": "workspace:^", - "@logto/react": "1.0.0-beta.13", - "@logto/schemas": "workspace:^", - "@parcel/core": "2.7.0", - "@parcel/transformer-sass": "2.7.0", + "@logto/core-kit": "1.0.0-beta.28", + "@logto/language-kit": "1.0.0-beta.28", + "@logto/phrases": "workspace:*", + "@logto/react": "1.0.0-beta.14", + "@logto/schemas": "workspace:*", + "@parcel/core": "2.8.0", + "@parcel/transformer-sass": "2.8.0", "@silverhand/eslint-config": "1.3.0", "@silverhand/eslint-config-react": "1.3.0", "@silverhand/ts-config": "1.2.1", @@ -35,14 +34,15 @@ "i18next": "^21.8.16", "i18next-browser-languagedetector": "^6.1.4", "lint-staged": "^13.0.0", - "parcel": "2.7.0", + "parcel": "2.8.0", "postcss": "^8.4.6", "prettier": "^2.7.1", "react": "^18.0.0", "react-dom": "^18.0.0", "react-i18next": "^11.18.3", "stylelint": "^14.9.1", - "typescript": "^4.7.4" + "typescript": "^4.9.4", + "zod": "^3.19.1" }, "engines": { "node": "^16.13.0 || ^18.12.0" diff --git a/packages/integration-tests/jest.config.js b/packages/integration-tests/jest.config.js new file mode 100644 index 000000000..d6e8961c6 --- /dev/null +++ b/packages/integration-tests/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('jest').Config} */ +const config = { + testPathIgnorePatterns: ['/node_modules/'], + setupFilesAfterEnv: ['./jest.setup.js'], + roots: ['./lib'], + moduleNameMapper: { + '^#src/(.*)\\.js(x)?$': '/lib/$1', + '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + }, +}; + +export default config; diff --git a/packages/integration-tests/jest.config.ts b/packages/integration-tests/jest.config.ts deleted file mode 100644 index d9955574a..000000000 --- a/packages/integration-tests/jest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Config } from '@silverhand/jest-config'; -import { merge } from '@silverhand/jest-config'; - -const config: Config.InitialOptions = merge({ - setupFilesAfterEnv: ['/jest.setup.js'], -}); - -export default config; diff --git a/packages/integration-tests/jest.config.ui.js b/packages/integration-tests/jest.config.ui.js new file mode 100644 index 000000000..dddbf5004 --- /dev/null +++ b/packages/integration-tests/jest.config.ui.js @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +const config = { + preset: 'jest-puppeteer', + moduleNameMapper: { + '^#src/(.*)\\.js(x)?$': '/lib/$1', + '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + }, +}; + +export default config; diff --git a/packages/integration-tests/jest.config.ui.ts b/packages/integration-tests/jest.config.ui.ts deleted file mode 100644 index 17c1f7cfb..000000000 --- a/packages/integration-tests/jest.config.ui.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { merge, Config } from '@silverhand/jest-config'; - -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, unicorn/prefer-module -const config: Config.InitialOptions = merge(require('jest-puppeteer/jest-preset')); - -export default config; diff --git a/packages/integration-tests/jest.setup.js b/packages/integration-tests/jest.setup.js index 89e48b851..f60a41079 100644 --- a/packages/integration-tests/jest.setup.js +++ b/packages/integration-tests/jest.setup.js @@ -2,10 +2,10 @@ // https://github.com/jsdom/jsdom/issues/1612 import { Crypto } from '@peculiar/webcrypto'; import dotenv from 'dotenv'; +import fetch from 'node-fetch'; import { TextDecoder, TextEncoder } from 'text-encoder'; -// eslint-disable-next-line unicorn/prefer-module -const fetch = require('node-fetch'); +const { jest } = import.meta; dotenv.config(); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 6b85b6150..3ceefc97a 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -5,30 +5,35 @@ "author": "Silverhand Inc. ", "license": "MPL-2.0", "private": true, + "type": "module", + "imports": { + "#src/*": "./lib/*" + }, "scripts": { - "test": "pnpm test:api && pnpm test:ui", - "test:api": "jest -i ./tests/api", - "test:ui": "jest -i --config=jest.config.ui.ts ./tests/ui", - "lint": "eslint --ext .ts src tests", + "build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", + "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "pnpm build && pnpm test:api && pnpm test:ui", + "test:api": "pnpm test:only -i ./lib/tests/api", + "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui", + "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "start": "pnpm test" }, "devDependencies": { "@jest/types": "^29.1.2", - "@logto/js": "1.0.0-beta.13", - "@logto/node": "1.0.0-beta.13", - "@logto/schemas": "workspace:^", + "@logto/js": "1.0.0-beta.14", + "@logto/node": "1.0.0-beta.14", + "@logto/schemas": "workspace:*", "@peculiar/webcrypto": "^1.3.3", "@silverhand/eslint-config": "1.3.0", "@silverhand/essentials": "^1.3.0", - "@silverhand/jest-config": "1.2.2", "@silverhand/ts-config": "1.2.1", "@types/jest": "^29.1.2", "@types/jest-environment-puppeteer": "^5.0.2", "@types/node": "^16.0.0", "dotenv": "^16.0.0", "eslint": "^8.21.0", - "got": "^11.8.5", + "got": "^12.5.3", "jest": "^29.1.2", "jest-puppeteer": "^6.1.1", "node-fetch": "^2.6.7", @@ -37,8 +42,7 @@ "prettier": "^2.7.1", "puppeteer": "^19.0.0", "text-encoder": "^0.0.4", - "ts-node": "^10.9.1", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "engines": { "node": "^16.13.0 || ^18.12.0" diff --git a/packages/integration-tests/src/__mocks__/connectors-mock.ts b/packages/integration-tests/src/__mocks__/connectors-mock.ts index 049e65ea5..a27714899 100644 --- a/packages/integration-tests/src/__mocks__/connectors-mock.ts +++ b/packages/integration-tests/src/__mocks__/connectors-mock.ts @@ -140,6 +140,45 @@ export const mockEmailConnectorConfig = { ], }; +export const mockStandardEmailConnectorId = 'mock-standard-email-service'; +export const mockStandardEmailConnectorConfig = { + apiKey: 'api-key-value', + fromEmail: 'noreply@logto.test.io', + fromName: 'from-name-value', + templates: [ + { + usageType: 'SignIn', + type: 'text/plain', + subject: 'Logto SignIn Template', + content: 'This is for sign-in purposes only. Your passcode is {{code}}.', + }, + { + usageType: 'Register', + type: 'text/plain', + subject: 'Logto Register Template', + content: 'This is for registering purposes only. Your passcode is {{code}}.', + }, + { + usageType: 'ForgotPassword', + type: 'text/plain', + subject: 'Logto Forgot Password Template', + content: 'This is for forgot-password purposes only. Your passcode is {{code}}.', + }, + { + usageType: 'Continue', + type: 'text/plain', + subject: 'Logto Continue Template', + content: 'This is for completing user profile purposes only. Your passcode is {{code}}.', + }, + { + usageType: 'Test', + type: 'text/plain', + subject: 'Logto Test Template', + content: 'This is for testing purposes only. Your passcode is {{code}}.', + }, + ], +}; + export const mockSocialConnectorId = 'mock-social-connector'; export const mockSocialConnectorTarget = 'mock-social'; export const mockSocialConnectorConfig = { diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index 4c7cf72f9..05bcbe4bf 100644 --- a/packages/integration-tests/src/api/admin-user.ts +++ b/packages/integration-tests/src/api/admin-user.ts @@ -1,6 +1,6 @@ import type { User } from '@logto/schemas'; -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; type CreateUserPayload = { primaryEmail?: string; diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index ae3eb2bcf..22736a479 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -1,6 +1,6 @@ -import got from 'got'; +import { got } from 'got'; -import { logtoUrl } from '@/constants'; +import { logtoUrl } from '#src/constants.js'; export default got.extend({ prefixUrl: new URL('/api', logtoUrl) }); diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index f441e143f..f251d25c0 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -5,7 +5,7 @@ import type { OidcClientMetadata, } from '@logto/schemas'; -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; export const createApplication = (name: string, type: ApplicationType) => authedAdminApi diff --git a/packages/integration-tests/src/api/connector.ts b/packages/integration-tests/src/api/connector.ts index 939d9505d..2f82d2f66 100644 --- a/packages/integration-tests/src/api/connector.ts +++ b/packages/integration-tests/src/api/connector.ts @@ -1,6 +1,6 @@ -import type { ConnectorResponse } from '@logto/schemas'; +import type { Connector, ConnectorResponse } from '@logto/schemas'; -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; export const listConnectors = async () => authedAdminApi.get('connectors').json(); @@ -8,25 +8,27 @@ export const listConnectors = async () => export const getConnector = async (connectorId: string) => authedAdminApi.get(`connectors/${connectorId}`).json(); -export const updateConnectorConfig = async (connectorId: string, config: Record) => +// FIXME @Darcy: correct use of `id` and `connectorId`. +export const postConnector = async (connectorId: string, metadata?: Record) => + authedAdminApi + .post({ + url: `connectors`, + json: { connectorId, metadata }, + }) + .json(); + +export const deleteConnectorById = async (id: string) => + authedAdminApi.delete({ url: `connectors/${id}` }).json(); + +export const updateConnectorConfig = async ( + connectorId: string, + config: Record, + metadata?: Record +) => authedAdminApi .patch({ url: `connectors/${connectorId}`, - json: { config }, - }) - .json(); - -export const enableConnector = async (connectorId: string) => - updateConnectorEnabledProperty(connectorId, true); - -export const disableConnector = async (connectorId: string) => - updateConnectorEnabledProperty(connectorId, false); - -const updateConnectorEnabledProperty = (connectorId: string, enabled: boolean) => - authedAdminApi - .patch({ - url: `connectors/${connectorId}/enabled`, - json: { enabled }, + json: { config, metadata }, }) .json(); diff --git a/packages/integration-tests/src/api/dashboard.ts b/packages/integration-tests/src/api/dashboard.ts index 11e3b1dce..8835bed54 100644 --- a/packages/integration-tests/src/api/dashboard.ts +++ b/packages/integration-tests/src/api/dashboard.ts @@ -1,4 +1,4 @@ -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; export type StatisticsData = { count: number; diff --git a/packages/integration-tests/src/api/index.ts b/packages/integration-tests/src/api/index.ts index 6283bed00..18b84987b 100644 --- a/packages/integration-tests/src/api/index.ts +++ b/packages/integration-tests/src/api/index.ts @@ -1,12 +1,12 @@ -export * from './resource'; -export * from './connector'; -export * from './application'; -export * from './sign-in-experience'; -export * from './admin-user'; -export * from './session'; -export * from './logs'; -export * from './dashboard'; -export * from './me'; -export * from './wellknown'; +export * from './resource.js'; +export * from './connector.js'; +export * from './application.js'; +export * from './sign-in-experience.js'; +export * from './admin-user.js'; +export * from './session.js'; +export * from './logs.js'; +export * from './dashboard.js'; +export * from './me.js'; +export * from './wellknown.js'; -export { default as api, authedAdminApi } from './api'; +export { default as api, authedAdminApi } from './api.js'; diff --git a/packages/integration-tests/src/api/logs.ts b/packages/integration-tests/src/api/logs.ts index 7382c860f..22c0469a4 100644 --- a/packages/integration-tests/src/api/logs.ts +++ b/packages/integration-tests/src/api/logs.ts @@ -1,6 +1,6 @@ import type { Log } from '@logto/schemas'; -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; export const getLogs = () => authedAdminApi.get('logs').json(); diff --git a/packages/integration-tests/src/api/me.ts b/packages/integration-tests/src/api/me.ts index be4ce11f1..28862076c 100644 --- a/packages/integration-tests/src/api/me.ts +++ b/packages/integration-tests/src/api/me.ts @@ -1,6 +1,6 @@ import type { ArbitraryObject, UserInfo } from '@logto/schemas'; -import api from './api'; +import api from './api.js'; export const getCurrentUserInfo = (userId: string) => api.get(`me`, { headers: { 'development-user-id': userId } }).json(); diff --git a/packages/integration-tests/src/api/resource.ts b/packages/integration-tests/src/api/resource.ts index e60049a89..5f988ce0f 100644 --- a/packages/integration-tests/src/api/resource.ts +++ b/packages/integration-tests/src/api/resource.ts @@ -1,9 +1,9 @@ import type { Resource, CreateResource } from '@logto/schemas'; import type { OptionsOfTextResponseBody } from 'got'; -import { generateResourceIndicator, generateResourceName } from '@/utils'; +import { generateResourceIndicator, generateResourceName } from '#src/utils.js'; -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; export const createResource = (name?: string, indicator?: string) => authedAdminApi diff --git a/packages/integration-tests/src/api/session.ts b/packages/integration-tests/src/api/session.ts index b650c645a..66485df35 100644 --- a/packages/integration-tests/src/api/session.ts +++ b/packages/integration-tests/src/api/session.ts @@ -1,6 +1,6 @@ import { PasscodeType } from '@logto/schemas'; -import api from './api'; +import api from './api.js'; type RedirectResponse = { redirectTo: string; diff --git a/packages/integration-tests/src/api/sign-in-experience.ts b/packages/integration-tests/src/api/sign-in-experience.ts index 9fda9d08b..231f3a76e 100644 --- a/packages/integration-tests/src/api/sign-in-experience.ts +++ b/packages/integration-tests/src/api/sign-in-experience.ts @@ -1,6 +1,6 @@ import type { SignInExperience } from '@logto/schemas'; -import { authedAdminApi } from './api'; +import { authedAdminApi } from './api.js'; export const getSignInExperience = () => authedAdminApi.get('sign-in-exp').json(); diff --git a/packages/integration-tests/src/api/wellknown.ts b/packages/integration-tests/src/api/wellknown.ts index eac1cf112..29d9aa114 100644 --- a/packages/integration-tests/src/api/wellknown.ts +++ b/packages/integration-tests/src/api/wellknown.ts @@ -1,6 +1,6 @@ import type { SignInExperience } from '@logto/schemas'; -import api from './api'; +import api from './api.js'; export const getWellKnownSignInExperience = (interactionCookie: string) => api diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index e5129e839..ea4e886ff 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -1,14 +1,14 @@ import type { LogtoConfig } from '@logto/node'; import LogtoClient from '@logto/node'; -import { demoAppApplicationId } from '@logto/schemas/lib/seeds'; +import { demoAppApplicationId } from '@logto/schemas/lib/seeds/index.js'; import { assert } from '@silverhand/essentials'; -import got from 'got'; +import { got } from 'got'; -import { consent } from '@/api'; -import { demoAppRedirectUri, logtoUrl } from '@/constants'; -import { extractCookie } from '@/utils'; +import { consent } from '#src/api/index.js'; +import { demoAppRedirectUri, logtoUrl } from '#src/constants.js'; +import { extractCookie } from '#src/utils.js'; -import { MemoryStorage } from './storage'; +import { MemoryStorage } from './storage.js'; export const defaultConfig = { endpoint: logtoUrl, diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts index f0057b425..0d0b31222 100644 --- a/packages/integration-tests/src/constants.ts +++ b/packages/integration-tests/src/constants.ts @@ -1,9 +1,18 @@ +import { SignInIdentifier } from '@logto/schemas'; import { demoAppApplicationId } from '@logto/schemas/lib/seeds'; -import { getEnv } from '@silverhand/essentials'; +import { assertEnv } from '@silverhand/essentials'; -export const logtoUrl = getEnv('INTEGRATION_TESTS_LOGTO_URL'); +export const logtoUrl = assertEnv('INTEGRATION_TESTS_LOGTO_URL'); export const discoveryUrl = `${logtoUrl}/oidc/.well-known/openid-configuration`; export const demoAppRedirectUri = `${logtoUrl}/${demoAppApplicationId}`; export const adminConsoleRedirectUri = `${logtoUrl}/console/callback`; + +export const signUpIdentifiers = { + username: [SignInIdentifier.Username], + email: [SignInIdentifier.Email], + sms: [SignInIdentifier.Sms], + emailOrSms: [SignInIdentifier.Email, SignInIdentifier.Sms], + none: [], +}; diff --git a/packages/integration-tests/src/helpers.ts b/packages/integration-tests/src/helpers.ts index 4af50b09c..97f51848b 100644 --- a/packages/integration-tests/src/helpers.ts +++ b/packages/integration-tests/src/helpers.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; -import type { User, SignUpIdentifier, SignIn } from '@logto/schemas'; +import type { User, SignIn, SignInIdentifier } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { HTTPError } from 'got'; @@ -9,17 +9,13 @@ import { createUser, registerUserWithUsernameAndPassword, signInWithPassword, - updateConnectorConfig, - enableConnector, bindWithSocial, getAuthWithSocial, signInWithSocial, updateSignInExperience, -} from '@/api'; -import MockClient from '@/client'; -import { generateUsername, generatePassword } from '@/utils'; - -import { mockSocialConnectorId } from './__mocks__/connectors-mock'; +} from '#src/api/index.js'; +import MockClient from '#src/client/index.js'; +import { generateUsername, generatePassword } from '#src/utils.js'; export const createUserByAdmin = (username?: string, password?: string, primaryEmail?: string) => { return createUser({ @@ -71,18 +67,12 @@ export const signIn = async ({ username, email, password }: SignInHelper) => { assert(client.isAuthenticated, new Error('Sign in failed')); }; -export const setUpConnector = async (connectorId: string, config: Record) => { - await updateConnectorConfig(connectorId, config); - const connector = await enableConnector(connectorId); - assert(connector.enabled, new Error('Connector Setup Failed')); -}; - export const setSignUpIdentifier = async ( - identifier: SignUpIdentifier, + identifiers: SignInIdentifier[], password = true, verify = true ) => { - await updateSignInExperience({ signUp: { identifier, password, verify } }); + await updateSignInExperience({ signUp: { identifiers, password, verify } }); }; export const setSignInMethod = async (methods: SignIn['methods']) => { @@ -109,7 +99,7 @@ export const readPasscode = async (): Promise => { return JSON.parse(content) as PasscodeRecord; }; -export const bindSocialToNewCreatedUser = async () => { +export const bindSocialToNewCreatedUser = async (connectorId: string) => { const username = generateUsername(); const password = generatePassword(); @@ -124,13 +114,10 @@ export const bindSocialToNewCreatedUser = async () => { await client.initSession(); assert(client.interactionCookie, new Error('Session not found')); - await signInWithSocial( - { state, connectorId: mockSocialConnectorId, redirectUri }, - client.interactionCookie - ); + await signInWithSocial({ state, connectorId, redirectUri }, client.interactionCookie); const response = await getAuthWithSocial( - { connectorId: mockSocialConnectorId, data: { state, redirectUri, code } }, + { connectorId, data: { state, redirectUri, code } }, client.interactionCookie ).catch((error: unknown) => error); @@ -146,7 +133,7 @@ export const bindSocialToNewCreatedUser = async () => { interactionCookie: client.interactionCookie, }); - await bindWithSocial(mockSocialConnectorId, client.interactionCookie); + await bindWithSocial(connectorId, client.interactionCookie); await client.processSession(redirectTo); diff --git a/packages/integration-tests/src/include.d/openapi-schema-validator.d.ts b/packages/integration-tests/src/include.d/openapi-schema-validator.d.ts new file mode 100644 index 000000000..753c04b90 --- /dev/null +++ b/packages/integration-tests/src/include.d/openapi-schema-validator.d.ts @@ -0,0 +1,32 @@ +/** + * There's an issue for `"moduleResolution": "nodenext"`, thus we need to copy type definitions to here. + * See: https://github.com/microsoft/TypeScript/issues/47848 https://github.com/microsoft/TypeScript/issues/49189 + */ + +declare module 'openapi-schema-validator' { + import type { ErrorObject } from 'ajv'; + import type { IJsonSchema, OpenAPI } from 'openapi-types'; + + export interface IOpenAPISchemaValidator { + /** + * Validate the provided OpenAPI doc against this validator's schema version and + * return the results. + */ + validate(document: OpenAPI.Document): OpenAPISchemaValidatorResult; + } + export interface OpenAPISchemaValidatorArgs { + version: number | string; + extensions?: IJsonSchema; + } + export interface OpenAPISchemaValidatorResult { + errors: ErrorObject[]; + } + class OpenAPISchemaValidator implements IOpenAPISchemaValidator { + private readonly validator; + constructor(args: OpenAPISchemaValidatorArgs); + validate(openapiDocument: OpenAPI.Document): OpenAPISchemaValidatorResult; + } + + // eslint-disable-next-line import/no-anonymous-default-export + export default { default: OpenAPISchemaValidator }; +} diff --git a/packages/integration-tests/tests/api/admin-user.test.ts b/packages/integration-tests/src/tests/api/admin-user.test.ts similarity index 82% rename from packages/integration-tests/tests/api/admin-user.test.ts rename to packages/integration-tests/src/tests/api/admin-user.test.ts index 65be770e5..3c7898d17 100644 --- a/packages/integration-tests/tests/api/admin-user.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.test.ts @@ -4,7 +4,7 @@ import { mockSocialConnectorConfig, mockSocialConnectorId, mockSocialConnectorTarget, -} from '@/__mocks__/connectors-mock'; +} from '#src/__mocks__/connectors-mock.js'; import { getUser, getUsers, @@ -12,8 +12,11 @@ import { deleteUser, updateUserPassword, deleteUserIdentity, -} from '@/api'; -import { createUserByAdmin, bindSocialToNewCreatedUser, setUpConnector } from '@/helpers'; + postConnector, + updateConnectorConfig, + deleteConnectorById, +} from '#src/api/index.js'; +import { createUserByAdmin, bindSocialToNewCreatedUser } from '#src/helpers.js'; describe('admin console user management', () => { it('should create user successfully', async () => { @@ -66,9 +69,10 @@ describe('admin console user management', () => { }); it('should delete user identities successfully', async () => { - await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig); + const { id } = await postConnector(mockSocialConnectorId); + await updateConnectorConfig(id, mockSocialConnectorConfig); - const createdUserId = await bindSocialToNewCreatedUser(); + const createdUserId = await bindSocialToNewCreatedUser(id); const userInfo = await getUser(createdUserId); expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget); @@ -78,5 +82,7 @@ describe('admin console user management', () => { const updatedUser = await getUser(createdUserId); expect(updatedUser.identities).not.toHaveProperty(mockSocialConnectorTarget); + + await deleteConnectorById(id); }); }); diff --git a/packages/integration-tests/tests/api/application.test.ts b/packages/integration-tests/src/tests/api/application.test.ts similarity index 94% rename from packages/integration-tests/tests/api/application.test.ts rename to packages/integration-tests/src/tests/api/application.test.ts index 2f089639a..a9aed13f6 100644 --- a/packages/integration-tests/tests/api/application.test.ts +++ b/packages/integration-tests/src/tests/api/application.test.ts @@ -2,7 +2,12 @@ import { ApplicationType } from '@logto/schemas'; import { demoAppApplicationId } from '@logto/schemas/lib/seeds'; import { HTTPError } from 'got'; -import { createApplication, getApplication, updateApplication, deleteApplication } from '@/api'; +import { + createApplication, + getApplication, + updateApplication, + deleteApplication, +} from '#src/api/index.js'; describe('admin console application', () => { it('should get demo app details successfully', async () => { diff --git a/packages/integration-tests/src/tests/api/connector.test.ts b/packages/integration-tests/src/tests/api/connector.test.ts new file mode 100644 index 000000000..bbf5d4208 --- /dev/null +++ b/packages/integration-tests/src/tests/api/connector.test.ts @@ -0,0 +1,158 @@ +import { HTTPError } from 'got'; + +import { + mockEmailConnectorConfig, + mockEmailConnectorId, + mockSmsConnectorConfig, + mockSmsConnectorId, + mockSocialConnectorConfig, + mockSocialConnectorId, + mockStandardEmailConnectorConfig, + mockStandardEmailConnectorId, +} from '#src/__mocks__/connectors-mock.js'; +import { + deleteConnectorById, + getConnector, + listConnectors, + postConnector, + sendEmailTestMessage, + sendSmsTestMessage, + updateConnectorConfig, +} from '#src/api/connector.js'; + +const connectorIdMap = new Map(); + +/* + * We'd better only use mock connectors in integration tests. + * Since we will refactor connectors soon, keep using some real connectors + * for testing updating configs and enabling/disabling for now. + */ +test('connector set-up flow', async () => { + const connectors = await listConnectors(); + await Promise.all( + connectors.map(async ({ id }) => { + await deleteConnectorById(id); + }) + ); + connectorIdMap.clear(); + + /* + * Set up social/SMS/email connectors + */ + await Promise.all( + [ + { connectorId: mockSmsConnectorId, config: mockSmsConnectorConfig }, + { connectorId: mockEmailConnectorId, config: mockEmailConnectorConfig }, + { connectorId: mockSocialConnectorId, config: mockSocialConnectorConfig }, + ].map(async ({ connectorId, config }) => { + const { id } = await postConnector(connectorId); + connectorIdMap.set(connectorId, id); + const updatedConnector = await updateConnectorConfig(id, config); + expect(updatedConnector.config).toEqual(config); + + // The result of getting a connector should be same as the result of updating a connector above. + const connector = await getConnector(id); + expect(connector.config).toEqual(config); + }) + ); + + /* + * It should update the connector config successfully when it is valid; otherwise, it should fail. + * We will test updating to the invalid connector config, that is the case not covered above. + */ + await expect( + updateConnectorConfig(connectorIdMap.get(mockSocialConnectorId), mockSmsConnectorConfig) + ).rejects.toThrow(HTTPError); + // To confirm the failed updating request above did not modify the original config, + // we check: the mock connector config should stay the same. + const mockSocialConnector = await getConnector(connectorIdMap.get(mockSocialConnectorId)); + expect(mockSocialConnector.config).toEqual(mockSocialConnectorConfig); + + /* + * Change to another SMS/Email connector + */ + const { id } = await postConnector(mockStandardEmailConnectorId, { + target: 'mock-standard-mail', + }); // TODO [LOG-4862]: update mock connector + await updateConnectorConfig(id, mockStandardEmailConnectorConfig, { + target: 'mock-standard-mail', + }); // TODO [LOG-4862]: update mock connector + connectorIdMap.set(mockStandardEmailConnectorId, id); + const currentConnectors = await listConnectors(); + expect( + currentConnectors.some((connector) => connector.connectorId === mockEmailConnectorId) + ).toBeFalsy(); + connectorIdMap.delete(mockEmailConnectorId); + expect( + currentConnectors.some((connector) => connector.connectorId === mockStandardEmailConnectorId) + ).toBeTruthy(); + expect( + currentConnectors.find((connector) => connector.connectorId === mockStandardEmailConnectorId) + ?.config + ).toEqual(mockStandardEmailConnectorConfig); + + /* + * Delete (i.e. disable) a connector + */ + await expect( + deleteConnectorById(connectorIdMap.get(mockStandardEmailConnectorId)) + ).resolves.not.toThrow(); + connectorIdMap.delete(mockStandardEmailConnectorId); + + /** + * List connectors after manually setting up connectors. + * The result of listing connectors should be same as the result of updating connectors above. + */ + expect(await listConnectors()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + id: connectorIdMap.get(mockSmsConnectorId), + connectorId: mockSmsConnectorId, + config: mockSmsConnectorConfig, + }), + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + id: connectorIdMap.get(mockSocialConnectorId), + connectorId: mockSocialConnectorId, + config: mockSocialConnectorConfig, + }), + ]) + ); +}); + +test('send SMS/email test message', async () => { + const connectors = await listConnectors(); + await Promise.all( + connectors.map(async ({ id }) => { + await deleteConnectorById(id); + }) + ); + connectorIdMap.clear(); + + await Promise.all( + [{ connectorId: mockSmsConnectorId }, { connectorId: mockEmailConnectorId }].map( + async ({ connectorId }) => { + const { id } = await postConnector(connectorId); + connectorIdMap.set(connectorId, id); + } + ) + ); + + const phone = '8612345678901'; + const email = 'test@example.com'; + + await expect( + sendSmsTestMessage(connectorIdMap.get(mockSmsConnectorId), phone, mockSmsConnectorConfig) + ).resolves.not.toThrow(); + await expect( + sendEmailTestMessage(connectorIdMap.get(mockEmailConnectorId), email, mockEmailConnectorConfig) + ).resolves.not.toThrow(); + await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError); + await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError); + + for (const [_connectorId, id] of connectorIdMap.entries()) { + // eslint-disable-next-line no-await-in-loop + await deleteConnectorById(id); + } +}); diff --git a/packages/integration-tests/tests/api/dashboard.test.ts b/packages/integration-tests/src/tests/api/dashboard.test.ts similarity index 86% rename from packages/integration-tests/tests/api/dashboard.test.ts rename to packages/integration-tests/src/tests/api/dashboard.test.ts index e0c324308..235abd3a5 100644 --- a/packages/integration-tests/tests/api/dashboard.test.ts +++ b/packages/integration-tests/src/tests/api/dashboard.test.ts @@ -1,13 +1,12 @@ -import { SignUpIdentifier } from '@logto/schemas'; - -import type { StatisticsData } from '@/api'; -import { getTotalUsersCount, getNewUsersData, getActiveUsersData } from '@/api'; -import { createUserByAdmin, registerNewUser, setSignUpIdentifier, signIn } from '@/helpers'; -import { generateUsername, generatePassword } from '@/utils'; +import type { StatisticsData } from '#src/api/index.js'; +import { getTotalUsersCount, getNewUsersData, getActiveUsersData } from '#src/api/index.js'; +import { signUpIdentifiers } from '#src/constants.js'; +import { createUserByAdmin, registerNewUser, setSignUpIdentifier, signIn } from '#src/helpers.js'; +import { generateUsername, generatePassword } from '#src/utils.js'; describe('admin console dashboard', () => { beforeAll(async () => { - await setSignUpIdentifier(SignUpIdentifier.Username); + await setSignUpIdentifier(signUpIdentifiers.username); }); it('should get total user count successfully', async () => { diff --git a/packages/integration-tests/tests/api/get-access-token.test.ts b/packages/integration-tests/src/tests/api/get-access-token.test.ts similarity index 89% rename from packages/integration-tests/tests/api/get-access-token.test.ts rename to packages/integration-tests/src/tests/api/get-access-token.test.ts index e635e4993..38dd7ba7f 100644 --- a/packages/integration-tests/tests/api/get-access-token.test.ts +++ b/packages/integration-tests/src/tests/api/get-access-token.test.ts @@ -5,11 +5,11 @@ import { managementResource } from '@logto/schemas/lib/seeds'; import { assert } from '@silverhand/essentials'; import fetch from 'node-fetch'; -import { signInWithPassword } from '@/api'; -import MockClient, { defaultConfig } from '@/client'; -import { logtoUrl } from '@/constants'; -import { createUserByAdmin } from '@/helpers'; -import { generateUsername, generatePassword } from '@/utils'; +import { signInWithPassword } from '#src/api/index.js'; +import MockClient, { defaultConfig } from '#src/client/index.js'; +import { logtoUrl } from '#src/constants.js'; +import { createUserByAdmin } from '#src/helpers.js'; +import { generateUsername, generatePassword } from '#src/utils.js'; describe('get access token', () => { const username = generateUsername(); diff --git a/packages/integration-tests/tests/api/health-check.test.ts b/packages/integration-tests/src/tests/api/health-check.test.ts similarity index 80% rename from packages/integration-tests/tests/api/health-check.test.ts rename to packages/integration-tests/src/tests/api/health-check.test.ts index 32415b430..1c44838eb 100644 --- a/packages/integration-tests/tests/api/health-check.test.ts +++ b/packages/integration-tests/src/tests/api/health-check.test.ts @@ -1,4 +1,4 @@ -import { api } from '@/api'; +import { api } from '#src/api/index.js'; describe('Health check', () => { it('should have a health state', async () => { diff --git a/packages/integration-tests/tests/api/logs.test.ts b/packages/integration-tests/src/tests/api/logs.test.ts similarity index 71% rename from packages/integration-tests/tests/api/logs.test.ts rename to packages/integration-tests/src/tests/api/logs.test.ts index 256143822..efcf89141 100644 --- a/packages/integration-tests/tests/api/logs.test.ts +++ b/packages/integration-tests/src/tests/api/logs.test.ts @@ -1,16 +1,16 @@ -import { SignUpIdentifier } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; -import { getLogs, getLog } from '@/api'; -import { registerNewUser, setSignUpIdentifier } from '@/helpers'; -import { generateUsername, generatePassword } from '@/utils'; +import { getLogs, getLog } from '#src/api/index.js'; +import { signUpIdentifiers } from '#src/constants.js'; +import { registerNewUser, setSignUpIdentifier } from '#src/helpers.js'; +import { generateUsername, generatePassword } from '#src/utils.js'; describe('admin console logs', () => { const username = generateUsername(); const password = generatePassword(); beforeAll(async () => { - await setSignUpIdentifier(SignUpIdentifier.Username); + await setSignUpIdentifier(signUpIdentifiers.username); }); it('should get logs and visit log details successfully', async () => { diff --git a/packages/integration-tests/tests/api/resource.test.ts b/packages/integration-tests/src/tests/api/resource.test.ts similarity index 97% rename from packages/integration-tests/tests/api/resource.test.ts rename to packages/integration-tests/src/tests/api/resource.test.ts index 83492b726..2391f8dfe 100644 --- a/packages/integration-tests/tests/api/resource.test.ts +++ b/packages/integration-tests/src/tests/api/resource.test.ts @@ -1,8 +1,8 @@ import { managementResource } from '@logto/schemas/lib/seeds'; import { HTTPError } from 'got'; -import { createResource, getResource, updateResource, deleteResource } from '@/api'; -import { generateResourceIndicator, generateResourceName } from '@/utils'; +import { createResource, getResource, updateResource, deleteResource } from '#src/api/index.js'; +import { generateResourceIndicator, generateResourceName } from '#src/utils.js'; describe('admin console api resources', () => { it('should get management api resource details successfully', async () => { diff --git a/packages/integration-tests/tests/api/session.test.ts b/packages/integration-tests/src/tests/api/session.test.ts similarity index 82% rename from packages/integration-tests/tests/api/session.test.ts rename to packages/integration-tests/src/tests/api/session.test.ts index 4e0c0cfee..a581a021b 100644 --- a/packages/integration-tests/tests/api/session.test.ts +++ b/packages/integration-tests/src/tests/api/session.test.ts @@ -1,4 +1,4 @@ -import { SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; import { assert } from '@silverhand/essentials'; @@ -7,7 +7,7 @@ import { mockEmailConnectorConfig, mockSmsConnectorId, mockSmsConnectorConfig, -} from '@/__mocks__/connectors-mock'; +} from '#src/__mocks__/connectors-mock.js'; import { sendRegisterUserWithEmailPasscode, verifyRegisterUserWithEmailPasscode, @@ -17,28 +17,33 @@ import { verifyRegisterUserWithSmsPasscode, sendSignInUserWithSmsPasscode, verifySignInUserWithSmsPasscode, - disableConnector, signInWithPassword, createUser, -} from '@/api'; -import MockClient from '@/client'; + listConnectors, + deleteConnectorById, + postConnector, + updateConnectorConfig, +} from '#src/api/index.js'; +import MockClient from '#src/client/index.js'; +import { signUpIdentifiers } from '#src/constants.js'; import { registerNewUser, signIn, - setUpConnector, readPasscode, createUserByAdmin, setSignUpIdentifier, setSignInMethod, -} from '@/helpers'; -import { generateUsername, generatePassword, generateEmail, generatePhone } from '@/utils'; +} from '#src/helpers.js'; +import { generateUsername, generatePassword, generateEmail, generatePhone } from '#src/utils.js'; + +const connectorIdMap = new Map(); describe('username and password flow', () => { const username = generateUsername(); const password = generatePassword(); beforeAll(async () => { - await setSignUpIdentifier(SignUpIdentifier.Username, true); + await setSignUpIdentifier(signUpIdentifiers.username, true); await setSignInMethod([ { identifier: SignInIdentifier.Username, @@ -63,8 +68,11 @@ describe('email and password flow', () => { assert(localPart && domain, new Error('Email address local part or domain is empty')); beforeAll(async () => { - await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig); - await setSignUpIdentifier(SignUpIdentifier.Email, true); + const { id } = await postConnector(mockEmailConnectorId); + await updateConnectorConfig(id, mockEmailConnectorConfig); + connectorIdMap.set(mockEmailConnectorId, id); + + await setSignUpIdentifier(signUpIdentifiers.email, true); await setSignInMethod([ { identifier: SignInIdentifier.Email, @@ -93,8 +101,19 @@ describe('email and password flow', () => { describe('email passwordless flow', () => { beforeAll(async () => { - await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig); - await setSignUpIdentifier(SignUpIdentifier.Email, false); + const connectors = await listConnectors(); + await Promise.all( + connectors.map(async ({ id }) => { + await deleteConnectorById(id); + }) + ); + connectorIdMap.clear(); + + const { id } = await postConnector(mockEmailConnectorId); + await updateConnectorConfig(id, mockEmailConnectorConfig); + connectorIdMap.set(mockEmailConnectorId, id); + + await setSignUpIdentifier(signUpIdentifiers.email, false); await setSignInMethod([ { identifier: SignInIdentifier.Username, @@ -175,14 +194,25 @@ describe('email passwordless flow', () => { }); afterAll(async () => { - void disableConnector(mockEmailConnectorId); + await deleteConnectorById(connectorIdMap.get(mockEmailConnectorId)); }); }); describe('sms passwordless flow', () => { beforeAll(async () => { - await setUpConnector(mockSmsConnectorId, mockSmsConnectorConfig); - await setSignUpIdentifier(SignUpIdentifier.Sms, false); + const connectors = await listConnectors(); + await Promise.all( + connectors.map(async ({ id }) => { + await deleteConnectorById(id); + }) + ); + connectorIdMap.clear(); + + const { id } = await postConnector(mockSmsConnectorId); + await updateConnectorConfig(id, mockSmsConnectorConfig); + connectorIdMap.set(mockSmsConnectorId, id); + + await setSignUpIdentifier(signUpIdentifiers.sms, false); await setSignInMethod([ { identifier: SignInIdentifier.Username, @@ -263,7 +293,7 @@ describe('sms passwordless flow', () => { }); afterAll(async () => { - void disableConnector(mockSmsConnectorId); + await deleteConnectorById(connectorIdMap.get(mockSmsConnectorId)); }); }); @@ -273,7 +303,7 @@ describe('sign-in and sign-out', () => { beforeAll(async () => { await createUserByAdmin(username, password); - await setSignUpIdentifier(SignUpIdentifier.Username); + await setSignUpIdentifier(signUpIdentifiers.username); }); it('verify sign-in and then sign-out', async () => { diff --git a/packages/integration-tests/tests/api/sign-in-experience.test.ts b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts similarity index 92% rename from packages/integration-tests/tests/api/sign-in-experience.test.ts rename to packages/integration-tests/src/tests/api/sign-in-experience.test.ts index 5893f6faa..2ba6e9025 100644 --- a/packages/integration-tests/tests/api/sign-in-experience.test.ts +++ b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts @@ -1,6 +1,6 @@ import { BrandingStyle } from '@logto/schemas'; -import { getSignInExperience, updateSignInExperience } from '@/api'; +import { getSignInExperience, updateSignInExperience } from '#src/api/index.js'; describe('admin console sign-in experience', () => { it('should get sign-in experience successfully', async () => { diff --git a/packages/integration-tests/tests/api/social-session.test.ts b/packages/integration-tests/src/tests/api/social-session.test.ts similarity index 68% rename from packages/integration-tests/tests/api/social-session.test.ts rename to packages/integration-tests/src/tests/api/social-session.test.ts index 381805968..35e198f90 100644 --- a/packages/integration-tests/tests/api/social-session.test.ts +++ b/packages/integration-tests/src/tests/api/social-session.test.ts @@ -1,4 +1,3 @@ -import { SignUpIdentifier } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { HTTPError } from 'got'; @@ -6,7 +5,7 @@ import { mockSocialConnectorId, mockSocialConnectorTarget, mockSocialConnectorConfig, -} from '@/__mocks__/connectors-mock'; +} from '#src/__mocks__/connectors-mock.js'; import { signInWithSocial, getAuthWithSocial, @@ -14,21 +13,30 @@ import { bindWithSocial, signInWithPassword, getUser, -} from '@/api'; -import MockClient from '@/client'; -import { setUpConnector, createUserByAdmin, setSignUpIdentifier } from '@/helpers'; -import { generateUsername, generatePassword } from '@/utils'; + postConnector, + updateConnectorConfig, + deleteConnectorById, +} from '#src/api/index.js'; +import MockClient from '#src/client/index.js'; +import { signUpIdentifiers } from '#src/constants.js'; +import { createUserByAdmin, setSignUpIdentifier } from '#src/helpers.js'; +import { generateUsername, generatePassword } from '#src/utils.js'; const state = 'foo_state'; const redirectUri = 'http://foo.dev/callback'; const code = 'auth_code_foo'; +const connectorIdMap = new Map(); + describe('social sign-in and register', () => { const socialUserId = crypto.randomUUID(); beforeAll(async () => { - await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig); - await setSignUpIdentifier(SignUpIdentifier.None, false); + const { id } = await postConnector(mockSocialConnectorId); + connectorIdMap.set(mockSocialConnectorId, id); + await updateConnectorConfig(id, mockSocialConnectorConfig); + + await setSignUpIdentifier(signUpIdentifiers.none, false); }); it('register with social', async () => { @@ -39,14 +47,14 @@ describe('social sign-in and register', () => { await expect( signInWithSocial( - { state, connectorId: mockSocialConnectorId, redirectUri }, + { state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri }, client.interactionCookie ) ).resolves.toBeTruthy(); const response = await getAuthWithSocial( { - connectorId: mockSocialConnectorId, + connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', data: { state, redirectUri, code, userId: socialUserId }, }, client.interactionCookie @@ -57,7 +65,7 @@ describe('social sign-in and register', () => { // Register with social const { redirectTo } = await registerWithSocial( - mockSocialConnectorId, + connectorIdMap.get(mockSocialConnectorId) ?? '', client.interactionCookie ); @@ -78,14 +86,14 @@ describe('social sign-in and register', () => { await expect( signInWithSocial( - { state, connectorId: mockSocialConnectorId, redirectUri }, + { state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri }, client.interactionCookie ) ).resolves.toBeTruthy(); const { redirectTo } = await getAuthWithSocial( { - connectorId: mockSocialConnectorId, + connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', data: { state, redirectUri, code, userId: socialUserId }, }, client.interactionCookie @@ -105,6 +113,13 @@ describe('social bind account', () => { await createUserByAdmin(username, password); }); + afterAll(async () => { + for (const [_connectorId, id] of connectorIdMap.entries()) { + // eslint-disable-next-line no-await-in-loop + await deleteConnectorById(id); + } + }); + it('bind new social account', async () => { const client = new MockClient(); @@ -113,13 +128,16 @@ describe('social bind account', () => { await expect( signInWithSocial( - { state, connectorId: mockSocialConnectorId, redirectUri }, + { state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri }, client.interactionCookie ) ).resolves.toBeTruthy(); const response = await getAuthWithSocial( - { connectorId: mockSocialConnectorId, data: { state, redirectUri, code } }, + { + connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', + data: { state, redirectUri, code }, + }, client.interactionCookie ).catch((error: unknown) => error); @@ -133,7 +151,7 @@ describe('social bind account', () => { }); await expect( - bindWithSocial(mockSocialConnectorId, client.interactionCookie) + bindWithSocial(connectorIdMap.get(mockSocialConnectorId) ?? '', client.interactionCookie) ).resolves.not.toThrow(); await client.processSession(redirectTo); diff --git a/packages/integration-tests/tests/api/swagger-check.test.ts b/packages/integration-tests/src/tests/api/swagger-check.test.ts similarity index 80% rename from packages/integration-tests/tests/api/swagger-check.test.ts rename to packages/integration-tests/src/tests/api/swagger-check.test.ts index c816bf614..cb25c7d3a 100644 --- a/packages/integration-tests/tests/api/swagger-check.test.ts +++ b/packages/integration-tests/src/tests/api/swagger-check.test.ts @@ -1,7 +1,9 @@ -import OpenApiSchemaValidator from 'openapi-schema-validator'; +import Validator from 'openapi-schema-validator'; import type { OpenAPI } from 'openapi-types'; -import { api } from '@/api'; +import { api } from '#src/api/index.js'; + +const { default: OpenApiSchemaValidator } = Validator; describe('Swagger check', () => { it('should provide a valid swagger.json', async () => { diff --git a/packages/integration-tests/tests/api/wellknown.test.ts b/packages/integration-tests/src/tests/api/wellknown.test.ts similarity index 86% rename from packages/integration-tests/tests/api/wellknown.test.ts rename to packages/integration-tests/src/tests/api/wellknown.test.ts index 3e682b197..8001f303f 100644 --- a/packages/integration-tests/tests/api/wellknown.test.ts +++ b/packages/integration-tests/src/tests/api/wellknown.test.ts @@ -1,9 +1,9 @@ import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; import { assert } from '@silverhand/essentials'; -import { getWellKnownSignInExperience } from '@/api'; -import MockClient from '@/client'; -import { adminConsoleRedirectUri } from '@/constants'; +import { getWellKnownSignInExperience } from '#src/api/index.js'; +import MockClient from '#src/client/index.js'; +import { adminConsoleRedirectUri } from '#src/constants.js'; describe('wellknown api', () => { it('get /.well-known/sign-in-exp for AC', async () => { @@ -16,7 +16,7 @@ describe('wellknown api', () => { expect(response).toMatchObject({ signUp: { - identifier: 'username', + identifiers: ['username'], password: true, verify: false, }, diff --git a/packages/integration-tests/tests/ui/smoke.test.ts b/packages/integration-tests/src/tests/ui/smoke.test.ts similarity index 81% rename from packages/integration-tests/tests/ui/smoke.test.ts rename to packages/integration-tests/src/tests/ui/smoke.test.ts index e41051b01..9aa405bb8 100644 --- a/packages/integration-tests/tests/ui/smoke.test.ts +++ b/packages/integration-tests/src/tests/ui/smoke.test.ts @@ -1,4 +1,4 @@ -import { logtoUrl } from '@/constants'; +import { logtoUrl } from '#src/constants.js'; describe('smoke testing', () => { it('opens with app element', async () => { diff --git a/packages/integration-tests/tests/api/connector.test.ts b/packages/integration-tests/tests/api/connector.test.ts deleted file mode 100644 index 47f0030fb..000000000 --- a/packages/integration-tests/tests/api/connector.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { ConnectorType } from '@logto/schemas'; -import { HTTPError } from 'got'; - -import { - mockEmailConnectorConfig, - mockEmailConnectorId, - mockSmsConnectorConfig, - mockSmsConnectorId, - mockSocialConnectorConfig, - mockSocialConnectorId, -} from '@/__mocks__/connectors-mock'; -import { - disableConnector, - enableConnector, - getConnector, - listConnectors, - sendEmailTestMessage, - sendSmsTestMessage, - updateConnectorConfig, -} from '@/api/connector'; - -/* - * We'd better only use mock connectors in integration tests. - * Since we will refactor connectors soon, keep using some real connectors - * for testing updating configs and enabling/disabling for now. - */ -test('connector set-up flow', async () => { - /* - * Set up social/SMS/email connectors - */ - await Promise.all( - [ - { id: mockSmsConnectorId, config: mockSmsConnectorConfig }, - { id: mockEmailConnectorId, config: mockEmailConnectorConfig }, - { id: mockSocialConnectorId, config: mockSocialConnectorConfig }, - ].map(async ({ id, config }) => { - const updatedConnector = await updateConnectorConfig(id, config); - expect(updatedConnector.config).toEqual(config); - const enabledConnector = await enableConnector(id); - expect(enabledConnector.enabled).toBeTruthy(); - - // The result of getting a connector should be same as the result of updating a connector above. - const connector = await getConnector(id); - expect(connector.enabled).toBeTruthy(); - expect(connector.config).toEqual(config); - }) - ); - - /* - * It should update the connector config successfully when it is valid; otherwise, it should fail. - * We will test updating to the invalid connector config, that is the case not covered above. - */ - await expect( - updateConnectorConfig(mockSocialConnectorId, mockSmsConnectorConfig) - ).rejects.toThrow(HTTPError); - // To confirm the failed updating request above did not modify the original config, - // we check: the mock connector config should stay the same. - const mockSocialConnector = await getConnector(mockSocialConnectorId); - expect(mockSocialConnector.config).toEqual(mockSocialConnectorConfig); - - /* - * Change to another SMS/Email connector - */ - await Promise.all( - [ - { id: mockSmsConnectorId, config: mockSmsConnectorConfig, type: ConnectorType.Sms }, - { id: mockEmailConnectorId, config: mockEmailConnectorConfig, type: ConnectorType.Email }, - ].map(async ({ id, config, type }) => { - const updatedConnector = await updateConnectorConfig(id, config); - expect(updatedConnector.config).toEqual(config); - const enabledConnector = await enableConnector(id); - expect(enabledConnector.enabled).toBeTruthy(); - - // There should be exactly one enabled SMS/email connector after changing to another SMS/email connector. - const connectorsAfterChanging = await listConnectors(); - const enabledConnectors = connectorsAfterChanging.filter( - (connector) => connector.type === type && connector.enabled - ); - expect(enabledConnectors.length).toEqual(1); - expect(enabledConnectors[0]?.id).toEqual(id); - }) - ); - - /* - * Delete (i.e. disable) a connector - * - * We have not provided the API to delete a connector for now. - * Deleting a connector using Admin Console means disabling a connector using Management API. - */ - const disabledMockEmailConnector = await disableConnector(mockEmailConnectorId); - expect(disabledMockEmailConnector.enabled).toBeFalsy(); - const mockEmailConnector = await getConnector(mockEmailConnectorId); - expect(mockEmailConnector.enabled).toBeFalsy(); - - /** - * List connectors after manually setting up connectors. - * The result of listing connectors should be same as the result of updating connectors above. - */ - expect(await listConnectors()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: mockEmailConnectorId, - config: mockEmailConnectorConfig, - enabled: false, - }), - expect.objectContaining({ - id: mockSmsConnectorId, - config: mockSmsConnectorConfig, - enabled: true, - }), - expect.objectContaining({ - id: mockSocialConnectorId, - config: mockSocialConnectorConfig, - enabled: true, - }), - ]) - ); -}); - -describe('send SMS/email test message', () => { - const phone = '8612345678901'; - const email = 'test@example.com'; - - it('should send the test message successfully when the config is valid', async () => { - await expect( - sendSmsTestMessage(mockSmsConnectorId, phone, mockSmsConnectorConfig) - ).resolves.not.toThrow(); - await expect( - sendEmailTestMessage(mockEmailConnectorId, email, mockEmailConnectorConfig) - ).resolves.not.toThrow(); - }); - - it('should fail to send the test message when the config is invalid', async () => { - await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError); - await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError); - }); -}); diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json index d978a7287..7cb9489e4 100644 --- a/packages/integration-tests/tsconfig.json +++ b/packages/integration-tests/tsconfig.json @@ -1,15 +1,17 @@ { "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { + "moduleResolution": "nodenext", + "module": "esnext", "isolatedModules": false, "allowJs": true, - "noEmit": true, + "outDir": "lib", "baseUrl": ".", "paths": { - "@/*": [ + "#src/*": [ "src/*" ] } }, - "include": ["tests", "src", "jest.*.ts", "jest.setup.js"] + "include": ["src"] } diff --git a/packages/phrases-ui/package.json b/packages/phrases-ui/package.json index 779eb1ccb..04f6f4cdd 100644 --- a/packages/phrases-ui/package.json +++ b/packages/phrases-ui/package.json @@ -5,6 +5,7 @@ "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", + "type": "module", "main": "lib/index.js", "publishConfig": { "access": "public" @@ -22,6 +23,7 @@ "scripts": { "precommit": "lint-staged", "build": "rm -rf lib/ && tsc", + "build:test": "pnpm build", "dev": "tsc --watch --preserveWatchOutput --incremental", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", @@ -31,8 +33,8 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { - "@logto/core-kit": "1.0.0-beta.20", - "@logto/language-kit": "1.0.0-beta.20", + "@logto/core-kit": "1.0.0-beta.28", + "@logto/language-kit": "1.0.0-beta.28", "@silverhand/essentials": "^1.3.0", "zod": "^3.19.1" }, @@ -42,7 +44,7 @@ "eslint": "^8.21.0", "lint-staged": "^13.0.0", "prettier": "^2.7.1", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "engines": { "node": "^16.13.0 || ^18.12.0" diff --git a/packages/phrases-ui/src/index.ts b/packages/phrases-ui/src/index.ts index 247666d59..c8280bc2b 100644 --- a/packages/phrases-ui/src/index.ts +++ b/packages/phrases-ui/src/index.ts @@ -4,20 +4,30 @@ import { languages } from '@logto/language-kit'; import type { NormalizeKeyPaths } from '@silverhand/essentials'; import { z } from 'zod'; -import de from './locales/de'; -import en from './locales/en'; -import fr from './locales/fr'; -import ko from './locales/ko'; -import ptPT from './locales/pt-pt'; -import trTR from './locales/tr-tr'; -import zhCN from './locales/zh-cn'; -import type { LocalePhrase } from './types'; +import de from './locales/de.js'; +import en from './locales/en.js'; +import fr from './locales/fr.js'; +import ko from './locales/ko.js'; +import ptBR from './locales/pt-br.js'; +import ptPT from './locales/pt-pt.js'; +import trTR from './locales/tr-tr.js'; +import zhCN from './locales/zh-cn.js'; +import type { LocalePhrase } from './types.js'; -export type { LocalePhrase } from './types'; +export type { LocalePhrase } from './types.js'; export type I18nKey = NormalizeKeyPaths; -export const builtInLanguages = ['de', 'en', 'fr', 'ko', 'pt-PT', 'tr-TR', 'zh-CN'] as const; +export const builtInLanguages = [ + 'de', + 'en', + 'fr', + 'ko', + 'pt-PT', + 'pt-BR', + 'tr-TR', + 'zh-CN', +] as const; export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({ value: languageTag, @@ -36,6 +46,7 @@ const resource: Resource = { fr, ko, 'pt-PT': ptPT, + 'pt-BR': ptBR, 'tr-TR': trTR, 'zh-CN': zhCN, }; diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index 7d62e439b..b393fd794 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -1,4 +1,4 @@ -import type { LocalePhrase } from '../types'; +import type { LocalePhrase } from '../types.js'; const translation = { input: { @@ -51,9 +51,9 @@ const translation = { continue_with: 'Weiter mit', create_account_id_exists: 'Das Konto mit {{type}} {{value}} existiert bereits, möchtest du dich anmelden?', - sign_in_id_does_not_exists: + sign_in_id_does_not_exist: 'Das Konto mit {{type}} {{value}} existiert nicht, möchtest du ein neues Konto erstellen?', - sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED + sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED forgot_password_id_does_not_exits: 'Das Konto mit {{type}} {{value}} existiert nicht.', bind_account_title: 'Konto verknüpfen', diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index c5d031512..9b20e56f2 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -49,9 +49,9 @@ const translation = { continue_with: 'Continue with', create_account_id_exists: 'The account with {{type}} {{value}} already exists, would you like to sign in?', - sign_in_id_does_not_exists: + sign_in_id_does_not_exist: 'The account with {{type}} {{value}} does not exist, would you like to create a new account?', - sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', + sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', bind_account_title: 'Link account', social_create_account: 'No account? You can create a new account and link.', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index dfdee2670..1ed001abc 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -1,4 +1,4 @@ -import type { LocalePhrase } from '../types'; +import type { LocalePhrase } from '../types.js'; const translation = { input: { @@ -51,9 +51,9 @@ const translation = { continue_with: 'Continuer avec', create_account_id_exists: 'Le compte avec {{type}} {{value}} existe déjà, voulez-vous vous connecter ?', - sign_in_id_does_not_exists: + sign_in_id_does_not_exist: "Le compte avec {{type}} {{value}} n'existe pas, voulez-vous créer un nouveau compte ?", - sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED + sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED bind_account_title: 'Lier le compte', social_create_account: 'Pas de compte ? Vous pouvez créer un nouveau compte et un lien.', diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index fbd4cb990..91ae10194 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -1,4 +1,4 @@ -import type { LocalePhrase } from '../types'; +import type { LocalePhrase } from '../types.js'; const translation = { input: { @@ -50,8 +50,8 @@ const translation = { resend_passcode: '비밀번호 재전송', continue_with: '계속하기', create_account_id_exists: '{{type}} {{value}} 계정이 이미 존재해요. 로그인하시겠어요?', - sign_in_id_does_not_exists: '{type}} {{value}} 계정이 존재하지 않아요. 새로 만드시겠어요?', - sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED + sign_in_id_does_not_exist: '{type}} {{value}} 계정이 존재하지 않아요. 새로 만드시겠어요?', + sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED bind_account_title: '계정 연동', social_create_account: '계정이 없으신가요? 새로운 계정을 만들고 연동해보세요.', diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts new file mode 100644 index 000000000..46f1df7e7 --- /dev/null +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -0,0 +1,109 @@ +import type { LocalePhrase } from '../types.js'; + +const translation = { + input: { + username: 'Nome de usuário', + password: 'Senha', + email: 'E-mail', + phone_number: 'Número de telefone', + confirm_password: 'Confirme a senha', + }, + secondary: { + sign_in_with: 'Entrar com {{methods, list(type: disjunction;)}}', + register_with: 'Criar conta com {{methods, list(type: disjunction;)}}', + social_bind_with: + 'Já tinha uma conta? Faça login no link {{methods, list(type: disjunction;)}} com sua identidade social.', + }, + action: { + sign_in: 'Entrar', + continue: 'Continuar', + create_account: 'Criar conta', + create: 'Criar', + enter_passcode: 'Digite o código de verificação', + confirm: 'Confirmar', + cancel: 'Cancelar', + save_password: 'Salvar', + bind: 'Link com {{address}}', + back: 'Voltar', + nav_back: 'Voltar', + agree: 'Aceito', + got_it: 'Entendido', + sign_in_with: 'Continuar com {{name}}', + forgot_password: 'Esqueceu sua senha?', + switch_to: 'Trocar para {{method}}', + sign_in_via_passcode: 'Entrar com código de verificação', + sign_in_via_password: 'Entrar com senha', + }, + description: { + email: 'e-mail', + phone_number: 'número de telefone', + reminder: 'Lembrete', + not_found: '404 Não Encontrado', + agree_with_terms: 'Eu li e concordo com os ', + agree_with_terms_modal: 'Para continuar, por favor, concorde com os .', + terms_of_use: 'Termos de uso', + create_account: 'Criar conta', + or: 'ou', + enter_passcode: 'O código de verificação foi enviado para o seu {{address}} {{target}}', + passcode_sent: 'O código de verificação foi reenviado', + resend_after_seconds: 'Reenviar depois {{seconds}} segundos', + resend_passcode: 'Reenviar código de verificação', + continue_with: 'Continue com', + create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de entrar?', + sign_in_id_does_not_exist: + 'A conta com {{type}} {{value}} não existe, gostaria de criar uma nova conta?', + sign_in_id_does_not_exist_alert: 'A conta com {{type}} {{value}} não existe.', + create_account_id_exists_alert: 'A conta com {{type}} {{value}} já existe', + bind_account_title: 'Link da conta', + social_create_account: 'Sem conta? Você pode criar uma nova conta e link.', + social_bind_account: 'Já tinha uma conta? Faça login para vinculá-lo à sua identidade social.', + social_bind_with_existing: + 'Encontramos uma conta relacionada, você pode vinculá-la diretamente.', + reset_password: 'Redefinir senha', + reset_password_description_email: + 'Digite o endereço de e-mail associado à sua conta e enviaremos por e-mail o código de verificação para redefinir sua senha.', + reset_password_description_sms: + 'Digite o número de telefone associado à sua conta e enviaremos a você o código de verificação para redefinir sua senha.', + new_password: 'Nova senha', + set_password: 'Configurar senha', + password_changed: 'Senha alterada', + no_account: 'Ainda não tem conta? ', + have_account: 'Já tinha uma conta?', + enter_password: 'Digite a senha', + enter_password_for: 'Entre com a senha para {{method}} {{value}}', + enter_username: 'Insira nome de usuário', + enter_username_description: + 'O nome de usuário é uma alternativa para entrar. O nome de usuário deve conter apenas letras, números e sublinhados.', + link_email: 'Link e-mail', + link_phone: 'Link telefone', + link_email_or_phone: 'Link e-mail ou telefone', + link_email_description: 'Para maior segurança, vincule seu e-mail à conta.', + link_phone_description: 'Para maior segurança, vincule seu telefone à conta.', + link_email_or_phone_description: + 'Para maior segurança, vincule seu e-mail ou telefone à conta.', + continue_with_more_information: 'Para maior segurança, preencha os detalhes da conta abaixo.', + }, + error: { + username_password_mismatch: 'Usuário e senha não correspondem', + username_required: 'Nome de usuário é obrigatório', + password_required: 'Senha é obrigatório', + username_exists: 'O nome de usuário já existe', + username_should_not_start_with_number: 'O nome de usuário não deve começar com um número', + username_valid_charset: 'O nome de usuário deve conter apenas letras, números ou sublinhados.', + invalid_email: 'O e-mail é inválido', + invalid_phone: 'O número de telefone é inválido', + password_min_length: 'A senha requer um mínimo de {{min}} caracteres', + passwords_do_not_match: 'Suas senhas não correspondem. Por favor, tente novamente.', + invalid_passcode: 'O código de verificação é inválido', + invalid_connector_auth: 'A autorização é inválida', + invalid_connector_request: 'Os dados do conector são inválidos', + unknown: 'Erro desconhecido. Por favor, tente novamente mais tarde.', + invalid_session: 'Sessão não encontrada. Volte e faça login novamente.', + }, +}; + +const ptBR: LocalePhrase = Object.freeze({ + translation, +}); + +export default ptBR; diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index ad55b8a3d..361453d8a 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -1,4 +1,4 @@ -import type { LocalePhrase } from '../types'; +import type { LocalePhrase } from '../types.js'; const translation = { input: { @@ -50,8 +50,8 @@ const translation = { resend_passcode: 'Reenviar senha', continue_with: 'Continuar com', create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de fazer login?', - sign_in_id_does_not_exists: 'A conta com {{type}} {{value}} não existe, gostaria de criar uma?', - sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED + sign_in_id_does_not_exist: 'A conta com {{type}} {{value}} não existe, gostaria de criar uma?', + sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED bind_account_title: 'Agregar conta', social_create_account: 'Sem conta? Pode criar uma nova e agregar.', diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index ef512dd86..24cbd41ee 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -1,4 +1,4 @@ -import type { LocalePhrase } from '../types'; +import type { LocalePhrase } from '../types.js'; const translation = { input: { @@ -50,9 +50,9 @@ const translation = { resend_passcode: 'Kodu Yeniden Gönder', continue_with: 'İle devam et', create_account_id_exists: '{{type}} {{value}} ile hesap mevcut, giriş yapmak ister misiniz?', - sign_in_id_does_not_exists: + sign_in_id_does_not_exist: '{{type}} {{value}} ile hesap mevcut değil, yeni bir hesap oluşturmak ister misiniz?', - sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED + sign_in_id_does_not_exist_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED bind_account_title: 'Hesap bağla', social_create_account: 'Hesabınız yok mu? Yeni bir hesap ve bağlantı oluşturabilirsiniz.', diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index 271d0d5b6..281b82d8d 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -1,4 +1,4 @@ -import type { LocalePhrase } from '../types'; +import type { LocalePhrase } from '../types.js'; const translation = { input: { @@ -50,8 +50,8 @@ const translation = { resend_passcode: '重发验证码', continue_with: '通过以下方式继续', create_account_id_exists: '{{ type }}为 {{ value }} 的帐号已存在,你要登录吗?', - sign_in_id_does_not_exists: '{{ type }}为 {{ value }} 的帐号不存在,你要创建一个新帐号吗?', - sign_in_id_does_not_exists_alert: '{{ type }}为 {{ value }} 的帐号不存在。', + sign_in_id_does_not_exist: '{{ type }}为 {{ value }} 的帐号不存在,你要创建一个新帐号吗?', + sign_in_id_does_not_exist_alert: '{{ type }}为 {{ value }} 的帐号不存在。', create_account_id_exists_alert: '{{ type }}为 {{ value }} 的帐号已存在', bind_account_title: '绑定帐号', social_create_account: '没有帐号?你可以创建一个帐号并绑定。', diff --git a/packages/phrases-ui/src/types.ts b/packages/phrases-ui/src/types.ts index 9d6e5a80b..0ed193edb 100644 --- a/packages/phrases-ui/src/types.ts +++ b/packages/phrases-ui/src/types.ts @@ -1,3 +1,3 @@ -import type en from './locales/en'; +import type en from './locales/en.js'; export type LocalePhrase = typeof en; diff --git a/packages/phrases-ui/tsconfig.json b/packages/phrases-ui/tsconfig.json index 22287725e..cd964b281 100644 --- a/packages/phrases-ui/tsconfig.json +++ b/packages/phrases-ui/tsconfig.json @@ -2,7 +2,9 @@ "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { "outDir": "lib", - "declaration": true + "declaration": true, + "moduleResolution": "nodenext", + "module": "esnext" }, "include": ["src"] } diff --git a/packages/phrases/package.json b/packages/phrases/package.json index 1cfd58f6d..8c0ae5f21 100644 --- a/packages/phrases/package.json +++ b/packages/phrases/package.json @@ -5,6 +5,7 @@ "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", + "type": "module", "main": "lib/index.js", "publishConfig": { "access": "public" @@ -19,6 +20,7 @@ "scripts": { "precommit": "lint-staged", "build": "rm -rf lib/ && tsc", + "build:test": "pnpm build", "dev": "tsc --watch --preserveWatchOutput --incremental", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", @@ -31,8 +33,8 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { - "@logto/core-kit": "1.0.0-beta.20", - "@logto/language-kit": "1.0.0-beta.20", + "@logto/core-kit": "1.0.0-beta.28", + "@logto/language-kit": "1.0.0-beta.28", "@silverhand/essentials": "^1.3.0", "zod": "^3.19.1" }, @@ -42,7 +44,7 @@ "eslint": "^8.21.0", "lint-staged": "^13.0.0", "prettier": "^2.7.1", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "eslintConfig": { "extends": "@silverhand" diff --git a/packages/phrases/src/index.ts b/packages/phrases/src/index.ts index e8a821620..f186cd2c5 100644 --- a/packages/phrases/src/index.ts +++ b/packages/phrases/src/index.ts @@ -4,20 +4,30 @@ import { languages } from '@logto/language-kit'; import type { NormalizeKeyPaths } from '@silverhand/essentials'; import { z } from 'zod'; -import de from './locales/de'; -import en from './locales/en'; -import fr from './locales/fr'; -import ko from './locales/ko'; -import ptPT from './locales/pt-pt'; -import trTR from './locales/tr-tr'; -import zhCN from './locales/zh-cn'; -import type { LocalPhrase } from './types'; +import de from './locales/de/index.js'; +import en from './locales/en/index.js'; +import fr from './locales/fr/index.js'; +import ko from './locales/ko/index.js'; +import ptBR from './locales/pt-br/index.js'; +import ptPT from './locales/pt-pt/index.js'; +import trTR from './locales/tr-tr/index.js'; +import zhCN from './locales/zh-cn/index.js'; +import type { LocalPhrase } from './types.js'; -export type { LocalPhrase } from './types'; +export type { LocalPhrase } from './types.js'; export type I18nKey = NormalizeKeyPaths; -export const builtInLanguages = ['de', 'en', 'fr', 'ko', 'pt-PT', 'tr-TR', 'zh-CN'] as const; +export const builtInLanguages = [ + 'de', + 'en', + 'fr', + 'ko', + 'pt-PT', + 'pt-BR', + 'tr-TR', + 'zh-CN', +] as const; export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({ value: languageTag, @@ -48,6 +58,7 @@ const resource: Resource = { fr, ko, 'pt-PT': ptPT, + 'pt-BR': ptBR, 'tr-TR': trTR, 'zh-CN': zhCN, }; diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 638adaab2..a5097f1dd 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -32,30 +32,34 @@ const errors = { provider_error: 'OIDC interner Fehler: {{message}}.', }, user: { - username_exists_register: 'Der Benutzername wurde registriert.', - email_exists_register: 'Die E-Mail wurde registriert.', - phone_exists_register: 'Die Telefonnummer wurde registriert.', + username_already_in_use: 'This username is already in use.', // UNTRANSLATED + email_already_in_use: 'This email is associated with an existing account.', // UNTRANSLATED + phone_already_in_use: 'This phone number is associated with an existing account.', // UNTRANSLATED invalid_email: 'Ungültige E-Mail.', invalid_phone: 'Ungültige Telefonnummer.', - email_not_exists: 'Die E-Mail wurde noch nicht registriert.', - phone_not_exists: 'Die Telefonnummer wurde noch nicht registriert.', - identity_not_exists: 'Die Identität wurde noch nicht registriert.', - identity_exists: 'Die Identität wurde registriert.', + email_not_exist: 'Die E-Mail wurde noch nicht registriert.', + phone_not_exist: 'Die Telefonnummer wurde noch nicht registriert.', + identity_not_exist: 'Die Identität wurde noch nicht registriert.', + identity_already_in_use: 'Die Identität wurde registriert.', invalid_role_names: 'Rollennamen ({{roleNames}}) sind ungültig', cannot_delete_self: 'Du kannst dich nicht selbst löschen.', - sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED - sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED + sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED + sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED same_password: 'Das neue Passwort muss sich vom alten unterscheiden.', - require_password: 'You need to set a password before signing-in.', // UNTRANSLATED - password_exists: 'Your password has been set.', // UNTRANSLATED - require_username: 'You need to set a username before signing-in.', // UNTRANSLATED - username_exists: 'This username is already in use.', // UNTRANSLATED - require_email: 'You need to add an email address before signing-in.', // UNTRANSLATED - email_exists: 'This email is associated with an existing account.', // UNTRANSLATED - require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED - sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED - require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED + password_required_in_profile: 'You need to set a password before signing-in.', // UNTRANSLATED + new_password_required_in_profile: 'You need to set a new password.', // UNTRANSLATED + password_exists_in_profile: 'Password already exists in your profile.', // UNTRANSLATED + username_required_in_profile: 'You need to set a username before signing-in.', // UNTRANSLATED + username_exists_in_profile: 'Username already exists in your profile.', // UNTRANSLATED + email_required_in_profile: 'You need to add an email address before signing-in.', // UNTRANSLATED + email_exists_in_profile: 'Your profile has already associated with an email address.', // UNTRANSLATED + phone_required_in_profile: 'You need to add a phone number before signing-in.', // UNTRANSLATED + phone_exists_in_profile: 'Your profile has already associated with a phone number.', // UNTRANSLATED + email_or_phone_required_in_profile: + 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED + user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, + missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.', @@ -76,6 +80,8 @@ const errors = { unauthorized: 'Bitte melde dich erst an.', unsupported_prompt_name: 'Nicht unterstützter prompt Name.', forgot_password_not_enabled: 'Forgot password is not enabled.', + verification_failed: + 'Die Verifizierung war nicht erfolgreich. Starte die Verifizierung neu und versuche es erneut.', }, connector: { // UNTRANSLATED @@ -99,6 +105,15 @@ const errors = { more_than_one_sms: 'The number of SMS connectors is larger then 1.', more_than_one_email: 'The number of Email connectors is larger then 1.', db_connector_type_mismatch: 'There is a connector in the DB that does not match the type.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', + invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', + can_not_modify_target: 'The connector target can not be modified.', + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", }, passcode: { phone_email_empty: 'Telefonnummer oder E-Mail darf nicht leer sein.', diff --git a/packages/phrases/src/locales/de/index.ts b/packages/phrases/src/locales/de/index.ts index 10ea8ed97..715f77192 100644 --- a/packages/phrases/src/locales/de/index.ts +++ b/packages/phrases/src/locales/de/index.ts @@ -1,6 +1,6 @@ -import type { LocalPhrase } from '../../types'; -import errors from './errors'; -import translation from './translation'; +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; const de: LocalPhrase = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/de/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/de/translation/admin-console/api-resource-details.ts index 900414932..15559caab 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: 'Zurück zu API Ressourcen', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', // UNTRANSLATED token_expiration_time_in_seconds: 'Token Ablaufzeit (in Sekunden)', token_expiration_time_in_seconds_placeholder: 'Gib die Ablaufzeit des Tokens ein', delete_description: diff --git a/packages/phrases/src/locales/de/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/de/translation/admin-console/api-resources.ts index b15b5094d..f0eab1642 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/api-resources.ts @@ -6,7 +6,7 @@ const api_resources = { api_name_placeholder: 'Gib einen API Namen ein', api_identifier: 'API Identifikator', api_identifier_tip: - 'Der eindeutige Identifikator der API Ressource muss eine absolute URI ohne Fragmentbezeichner (#) sein. Entspricht dem Ressourcen Parameter in OAuth 2.0.', + 'Der eindeutige Identifikator der API Ressource muss eine absolute URI ohne Fragmentbezeichner (#) sein. Entspricht dem Ressourcen Parameter in OAuth 2.0.', api_resource_created: 'Die API Ressource {{name}} wurde erfolgreich angelegt', api_identifier_placeholder: 'https://dein-api-identifikator/', }; diff --git a/packages/phrases/src/locales/de/translation/admin-console/application-details.ts b/packages/phrases/src/locales/de/translation/admin-console/application-details.ts index 1311893ab..b804c71d4 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/application-details.ts @@ -1,22 +1,29 @@ const application_details = { back_to_applications: 'Zurück zu Anwendungen', check_guide: 'Zur Anleitung', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', // UNTRANSLATED advanced_settings: 'Erweiterte Einstellungen', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', // UNTRANSLATED application_name: 'Anwendungsname', application_name_placeholder: 'Meine App', description: 'Beschreibung', description_placeholder: 'Gib eine Beschreibung ein', authorization_endpoint: 'Autorisierungs-Endpoint', authorization_endpoint_tip: - 'Der Endpoint, der für die Authentifizierung und Autorisierung via OpenID Connect verwendet wird.', + 'Der Endpoint, der für die Authentifizierung und Autorisierung via OpenID Connect verwendet wird.', application_id: 'App ID', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', // UNTRANSLATED application_secret: 'App Geheimnis', redirect_uri: 'Umleitungs-URI', redirect_uris: 'Umleitungs-URIs', redirect_uri_placeholder: 'https://deine.website.de/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - 'URI zu der der Benutzer nach der Anmeldung (egal ob erfolgreich oder nicht) weitergeleitet wird. See OpenID Connect AuthRequest for more info.', + 'URI zu der der Benutzer nach der Anmeldung (egal ob erfolgreich oder nicht) weitergeleitet wird. See OpenID Connect AuthRequest for more info.', post_sign_out_redirect_uri: 'Post Sign-out Umleitungs-URI', post_sign_out_redirect_uris: 'Post Sign-out Umleitungs-URIs', post_sign_out_redirect_uri_placeholder: 'https://deine.website.de/home', @@ -25,8 +32,7 @@ const application_details = { cors_allowed_origins: 'CORS allowed origins', cors_allowed_origins_placeholder: 'https://your.website.de', cors_allowed_origins_tip: - 'Es sind standardmäßig alle Umleitungs-URI Origins erlaubt. Normalerweise ist dieses Feld nicht erforderlich.', - add_another: 'Weitere hinzufügen', + 'Es sind standardmäßig alle Umleitungs-URI Origins erlaubt. Normalerweise ist dieses Feld nicht erforderlich. See the MDN doc for detailed info.', // UNTRANSLATED id_token_expiration: 'ID Token Ablaufzeit', refresh_token_expiration: 'Refresh Token Ablaufzeit', token_endpoint: 'Token Endpoint', diff --git a/packages/phrases/src/locales/de/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/de/translation/admin-console/connector-details.ts index 23f210699..85b6e9f17 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: 'Zurück zu Connectoren', check_readme: 'Zur README', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', // UNTRANSLATED save_error_empty_config: 'Bitte fülle die Konfiguration aus', send: 'Senden', send_error_invalid_format: 'Ungültige Eingabe', diff --git a/packages/phrases/src/locales/de/translation/admin-console/connectors.ts b/packages/phrases/src/locales/de/translation/admin-console/connectors.ts index 012a55a52..48cda6dd4 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/connectors.ts @@ -30,6 +30,24 @@ const connectors = { }, guide: { subtitle: 'Eine Schritt-für-Schritt-Anleitung zur Konfiguration deines Connectors', + connector_setting: 'Connector setting', // UNTRANSLATED + name: 'Connector name', // UNTRANSLATED + name_tip: 'Connector button’s name will display as "Continue with {{Connector Name}}".', // UNTRANSLATED + logo: 'Connector logo URL', // UNTRANSLATED + logo_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_tip: 'The logo image will also display on the connector button.', // UNTRANSLATED + logo_dark: 'Connector logo URL (Dark mode)', // UNTRANSLATED + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_dark_tip: + 'This will be used when opening “Enable dark mode” in the setting of sign in experience.', // UNTRANSLATED + logo_dark_collapse: 'Collapse', // UNTRANSLATED + logo_dark_show: 'Show "Logo for dark mode"', // UNTRANSLATED + target: 'Connector identity target', // UNTRANSLATED + target_tip: 'A unique identifier for the connector.', // UNTRANSLATED + config: 'Enter your JSON here', // UNTRANSLATED + sync_profile: 'Sync profile information from the social provider', // UNTRANSLATED + sync_profile_only_at_register: 'Only sync at register', // UNTRANSLATED + sync_profile_each_sign_in: 'Always sync at each sign-in', // UNTRANSLATED }, platform: { universal: 'Universal', diff --git a/packages/phrases/src/locales/de/translation/admin-console/general.ts b/packages/phrases/src/locales/de/translation/admin-console/general.ts index 070a3c097..03af0d8ec 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: 'Speichern', save_changes: 'Änderungen speichern', saved: 'Gespeichert!', + discard: 'Discard', // UNTRANSLATED loading: 'Lade...', redirecting: 'Weiterleiten...', add: 'Hinzufügen', @@ -29,7 +30,7 @@ const general = { copying: 'Kopiere', copied: 'Kopiert', required: 'Erforderlich', - add_another: '+ Weitere hinzufügen', + add_another: 'Weitere hinzufügen', deletion_confirmation: 'Willst du {{title}} wirklich löschen?', settings_nav: 'Einstellungen', unsaved_changes_warning: @@ -38,6 +39,9 @@ const general = { stay_on_page: 'Auf Seite bleiben', type_to_search: 'Tippe um zu suchen', got_it: 'Got it', // UNTRANSLATED + page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED + learn_more: 'Learn more', // UNTRANSLATED + tab_errors: '{{count, number}} errors', // UNTRANSLATED }; export default general; diff --git a/packages/phrases/src/locales/de/translation/admin-console/index.ts b/packages/phrases/src/locales/de/translation/admin-console/index.ts index 2964cba88..816360791 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: 'Admin Konsole', diff --git a/packages/phrases/src/locales/de/translation/admin-console/settings.ts b/packages/phrases/src/locales/de/translation/admin-console/settings.ts index e20d8050e..fd7b868e2 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: 'Einstellungen', description: 'Verwalte die globalen Einstellungen', - tabs: { - general: 'Allgemein', - }, + settings: 'Einstellungen', custom_domain: 'Benutzerdefinierte Domain', language: 'Sprache', appearance: 'Darstellung', diff --git a/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts index 81feba345..5ca5ea6d8 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts @@ -46,6 +46,7 @@ const sign_in_exp = { password_auth: 'Password', // UNTRANSLATED verification_code_auth: 'Verification code', // UNTRANSLATED auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED + require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED }, social_sign_in: { title: 'SOCIAL SIGN-IN', // UNTRANSLATED @@ -137,8 +138,8 @@ const sign_in_exp = { '{{language}} ist als Standardsprache eingestellt und kann nicht gelöscht werden. ', got_it: 'Alles klar', }, - authentication: { - title: 'AUTHENTIFIZIERUNG', + advanced_options: { + title: 'ERWEITERTE OPTIONEN', enable_create_account: 'Aktiviere Registrierung', enable_create_account_description: 'Aktiviere oder deaktiviere Konto Registrierung. Wenn diese Funktion deaktiviert ist, können deine Kunden keine Konten über die Anmeldeoberfläche erstellen, aber du kannst immer noch Benutzer in der Admin Konsole hinzufügen.', @@ -150,13 +151,14 @@ const sign_in_exp = { setup_warning: { no_connector: '', no_connector_sms: - 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in.', // UNTRANSLATED + 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_email: - 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in.', // UNTRANSLATED + 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_social: - 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in.', // UNTRANSLATED + 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_added_social_connector: 'Du hast jetzt ein paar Social Connectoren eingerichtet. Füge jetzt einige zu deinem Anmeldeerlebnis hinzu.', + setup_link: 'Set up', }, save_alert: { description: diff --git a/packages/phrases/src/locales/de/translation/admin-console/user-details.ts b/packages/phrases/src/locales/de/translation/admin-console/user-details.ts index acc717872..b886ad5c9 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/user-details.ts @@ -17,6 +17,9 @@ const user_details = { new_password: 'Neues Passwort:', }, tab_logs: 'Benutzer-Logs', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', // UNTRANSLATED field_email: 'Primäre E-Mail', field_phone: 'Primäre Telefonnummer', field_username: 'Benutzername', diff --git a/packages/phrases/src/locales/de/translation/index.ts b/packages/phrases/src/locales/de/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/de/translation/index.ts +++ b/packages/phrases/src/locales/de/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index c3dc74bbc..eed909db1 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -32,30 +32,34 @@ const errors = { provider_error: 'OIDC Internal Error: {{message}}.', }, user: { - username_exists_register: 'The username has been registered.', - email_exists_register: 'The email address has been registered.', - phone_exists_register: 'The phone number has been registered.', + username_already_in_use: 'This username is already in use.', + email_already_in_use: 'This email is associated with an existing account.', + phone_already_in_use: 'This phone number is associated with an existing account.', invalid_email: 'Invalid email address.', invalid_phone: 'Invalid phone number.', - email_not_exists: 'The email address has not been registered yet.', - phone_not_exists: 'The phone number has not been registered yet.', - identity_not_exists: 'The social account has not been registered yet.', - identity_exists: 'The social account has been registered.', - invalid_role_names: 'role names ({{roleNames}}) are not valid', + email_not_exist: 'The email address has not been registered yet.', + phone_not_exist: 'The phone number has not been registered yet.', + identity_not_exist: 'The social account has not been registered yet.', + identity_already_in_use: 'The social account has been associated with an existing account.', + invalid_role_names: 'Role names ({{roleNames}}) are not valid.', cannot_delete_self: 'You cannot delete yourself.', - sign_up_method_not_enabled: 'This sign up method is not enabled.', - sign_in_method_not_enabled: 'This sign in method is not enabled.', + sign_up_method_not_enabled: 'This sign-up method is not enabled.', + sign_in_method_not_enabled: 'This sign-in method is not enabled.', same_password: 'New password cannot be the same as your old password.', - require_password: 'You need to set a password before signing-in.', - password_exists: 'Your password has been set.', - require_username: 'You need to set a username before signing-in.', - username_exists: 'This username is already in use.', - require_email: 'You need to add an email address before signing-in.', - email_exists: 'This email is associated with an existing account.', - require_sms: 'You need to add a phone number before signing-in.', - sms_exists: 'This phone number is associated with an existing account.', - require_email_or_sms: 'You need to add an email address or phone number before signing-in.', + password_required_in_profile: 'You need to set a password before signing-in.', + new_password_required_in_profile: 'You need to set a new password.', + password_exists_in_profile: 'Password already exists in your profile.', + username_required_in_profile: 'You need to set a username before signing-in.', + username_exists_in_profile: 'Username already exists in your profile.', + email_required_in_profile: 'You need to add an email address before signing-in.', + email_exists_in_profile: 'Your profile has already associated with an email address.', + phone_required_in_profile: 'You need to add a phone number before signing-in.', + phone_exists_in_profile: 'Your profile has already associated with a phone number.', + email_or_phone_required_in_profile: + 'You need to add an email address or phone number before signing-in.', suspended: 'This account is suspended.', + user_not_exist: 'User with {{ identity }} does not exist.', + missing_profile: 'You need to provide additional info before signing-in.', }, password: { unsupported_encryption_method: 'The encryption method {{name}} is not supported.', @@ -76,6 +80,8 @@ const errors = { unauthorized: 'Please sign in first.', unsupported_prompt_name: 'Unsupported prompt name.', forgot_password_not_enabled: 'Forgot password is not enabled.', + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', }, connector: { general: 'An unexpected error occurred in connector.{{errorDescription}}', @@ -98,6 +104,15 @@ const errors = { more_than_one_sms: 'The number of SMS connectors is larger then 1.', more_than_one_email: 'The number of Email connectors is larger then 1.', db_connector_type_mismatch: 'There is a connector in the DB that does not match the type.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', + invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', + can_not_modify_target: 'The connector target can not be modified.', + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", }, passcode: { phone_email_empty: 'Both phone and email are empty.', diff --git a/packages/phrases/src/locales/en/index.ts b/packages/phrases/src/locales/en/index.ts index c6d488122..f6f6956f7 100644 --- a/packages/phrases/src/locales/en/index.ts +++ b/packages/phrases/src/locales/en/index.ts @@ -1,5 +1,5 @@ -import errors from './errors'; -import translation from './translation'; +import errors from './errors.js'; +import translation from './translation/index.js'; const en = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/en/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/en/translation/admin-console/api-resource-details.ts index 4e624ee19..f64c79708 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: 'Back to API resources', + settings: 'Settings', + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', token_expiration_time_in_seconds: 'Token expiration time (in seconds)', token_expiration_time_in_seconds_placeholder: 'Enter your token expiration time', delete_description: diff --git a/packages/phrases/src/locales/en/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/en/translation/admin-console/api-resources.ts index 06850a981..7b9aa8130 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/api-resources.ts @@ -6,7 +6,7 @@ const api_resources = { api_name_placeholder: 'Enter your API name', api_identifier: 'API identifier', api_identifier_tip: - 'The unique identifier to the API resource. It must be an absolute URI and has no fragment (#) component. Equals to the resource parameter in OAuth 2.0.', + 'The unique identifier to the API resource. It must be an absolute URI and has no fragment (#) component. Equals to the resource parameter in OAuth 2.0.', api_resource_created: 'The API resource {{name}} has been successfully created', api_identifier_placeholder: 'https://your-api-identifier/', }; diff --git a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts index d22a6c07b..4d6724103 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts @@ -1,22 +1,29 @@ const application_details = { back_to_applications: 'Back to Applications', check_guide: 'Check Guide', + settings: 'Settings', + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', advanced_settings: 'Advanced settings', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', application_name: 'Application name', application_name_placeholder: 'My App', description: 'Description', description_placeholder: 'Enter your application description', authorization_endpoint: 'Authorization endpoint', authorization_endpoint_tip: - "The endpoint to perform authentication and authorization. It's used for OpenID Connect Authentication.", + "The endpoint to perform authentication and authorization. It's used for OpenID Connect Authentication.", application_id: 'App ID', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', application_secret: 'App Secret', redirect_uri: 'Redirect URI', redirect_uris: 'Redirect URIs', redirect_uri_placeholder: 'https://your.website.com/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - 'The URI redirects after a user sign-in (whether successful or not). See OpenID Connect AuthRequest for more info.', + 'The URI redirects after a user sign-in (whether successful or not). See OpenID Connect AuthRequest for more info.', post_sign_out_redirect_uri: 'Post Sign-out Redirect URI', post_sign_out_redirect_uris: 'Post Sign-out Redirect URIs', post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home', @@ -25,8 +32,7 @@ const application_details = { cors_allowed_origins: 'CORS allowed origins', cors_allowed_origins_placeholder: 'https://your.website.com', cors_allowed_origins_tip: - 'By default, all the origins of Redirect URIs will be allowed. Usually no action is required for this field.', - add_another: 'Add Another', + 'By default, all the origins of Redirect URIs will be allowed. Usually no action is required for this field. See the MDN doc for detailed info.', id_token_expiration: 'ID Token expiration', refresh_token_expiration: 'Refresh Token expiration', token_endpoint: 'Token Endpoint', diff --git a/packages/phrases/src/locales/en/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/en/translation/admin-console/connector-details.ts index abef0e54e..c1b98b4b2 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: 'Back to Connectors', check_readme: 'Check README', + settings: 'Settings', + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', save_error_empty_config: 'Please enter config', send: 'Send', send_error_invalid_format: 'Invalid input', diff --git a/packages/phrases/src/locales/en/translation/admin-console/connectors.ts b/packages/phrases/src/locales/en/translation/admin-console/connectors.ts index 993947c0a..2136e7cd3 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/connectors.ts @@ -30,6 +30,24 @@ const connectors = { }, guide: { subtitle: 'A step by step guide to configure your connector', + connector_setting: 'Connector setting', + name: 'Connector name', + name_tip: 'Connector button’s name will display as "Continue with {{Connector Name}}".', + logo: 'Connector logo URL', + logo_placeholder: 'https://your.cdn.domain/logo.png', + logo_tip: 'The logo image will also display on the connector button.', + logo_dark: 'Connector logo URL (Dark mode)', + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', + logo_dark_tip: + 'This will be used when opening “Enable dark mode” in the setting of sign in experience.', + logo_dark_collapse: 'Collapse', + logo_dark_show: 'Show "Logo for dark mode"', + target: 'Connector identity target', + target_tip: 'A unique identifier for the connector.', + config: 'Enter your JSON here', + sync_profile: 'Sync profile information from the social provider', + sync_profile_only_at_register: 'Only sync at register', + sync_profile_each_sign_in: 'Always sync at each sign-in', }, platform: { universal: 'Universal', diff --git a/packages/phrases/src/locales/en/translation/admin-console/general.ts b/packages/phrases/src/locales/en/translation/admin-console/general.ts index 3701119b3..809110051 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: 'Save', save_changes: 'Save Changes', saved: 'Saved!', + discard: 'Discard', loading: 'Loading...', redirecting: 'Redirecting...', add: 'Add', @@ -29,7 +30,7 @@ const general = { copying: 'Copying', copied: 'Copied', required: 'Required', - add_another: '+ Add Another', + add_another: 'Add Another', deletion_confirmation: 'Are you sure you want to delete this {{title}}?', settings_nav: 'Settings', unsaved_changes_warning: 'You have made some changes. Are you sure you want to leave this page?', @@ -37,6 +38,9 @@ const general = { stay_on_page: 'Stay on Page', type_to_search: 'Type to search', got_it: 'Got it', + page_info: '{{min, number}}-{{max, number}} of {{total, number}}', + learn_more: 'Learn more', + tab_errors: '{{count, number}} errors', }; export default general; diff --git a/packages/phrases/src/locales/en/translation/admin-console/index.ts b/packages/phrases/src/locales/en/translation/admin-console/index.ts index e5384fe80..102cfde03 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: 'Admin Console', diff --git a/packages/phrases/src/locales/en/translation/admin-console/settings.ts b/packages/phrases/src/locales/en/translation/admin-console/settings.ts index 967c8a542..39e06993d 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: 'Settings', description: 'Manage the global settings', - tabs: { - general: 'General', - }, + settings: 'Settings', custom_domain: 'Custom domain', language: 'Language', appearance: 'Appearance', diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts index 52a0cd985..91943ca55 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts @@ -68,6 +68,7 @@ const sign_in_exp = { password_auth: 'Password', verification_code_auth: 'Verification code', auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', + require_auth_factor: 'You have to select at least one authentication factor.', }, social_sign_in: { title: 'SOCIAL SIGN-IN', @@ -134,8 +135,8 @@ const sign_in_exp = { '{{language}} is set as your default language and can’t be deleted. ', got_it: 'Got It', }, - authentication: { - title: 'AUTHENTICATION', + advanced_options: { + title: 'ADVANCED OPTIONS', enable_user_registration: 'Enable user registration', enable_user_registration_description: 'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', @@ -144,13 +145,14 @@ const sign_in_exp = { setup_warning: { no_connector: '', no_connector_sms: - 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in.', + 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in. {{link}} in "Connectors"', no_connector_email: - 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in.', + 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in. {{link}} in "Connectors"', no_connector_social: - 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in.', + 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in. {{link}} in "Connectors"', no_added_social_connector: 'You’ve set up a few social connectors now. Make sure to add some to your sign in experience.', + setup_link: 'Set up', }, save_alert: { description: diff --git a/packages/phrases/src/locales/en/translation/admin-console/user-details.ts b/packages/phrases/src/locales/en/translation/admin-console/user-details.ts index df5f75131..031a4761e 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/user-details.ts @@ -15,6 +15,9 @@ const user_details = { new_password: 'New password:', }, tab_logs: 'User logs', + settings: 'Settings', + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', field_email: 'Primary email', field_phone: 'Primary phone', field_username: 'Username', diff --git a/packages/phrases/src/locales/en/translation/index.ts b/packages/phrases/src/locales/en/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/en/translation/index.ts +++ b/packages/phrases/src/locales/en/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 260640487..97ff0bec0 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -33,30 +33,34 @@ const errors = { provider_error: "Erreur interne de l'OIDC : {{message}}.", }, user: { - username_exists_register: "Le nom d'utilisateur a été enregistré.", - email_exists_register: "L'adresse email a été enregistrée.", - phone_exists_register: 'Le numéro de téléphone a été enregistré', + username_already_in_use: 'This username is already in use.', // UNTRANSLATED + email_already_in_use: 'This email is associated with an existing account.', // UNTRANSLATED + phone_already_in_use: 'This phone number is associated with an existing account.', // UNTRANSLATED invalid_email: 'Addresse email incorrecte.', invalid_phone: 'Numéro de téléphone incorrect.', - email_not_exists: "L'adresse e-mail n'a pas encore été enregistrée.", - phone_not_exists: "Le numéro de téléphone n'a pas encore été enregistré.", - identity_not_exists: "Le compte social n'a pas encore été enregistré.", - identity_exists: 'Le compte social a été enregistré.', - invalid_role_names: 'les noms de rôles ({{roleNames}}) ne sont pas valides', + email_not_exist: "L'adresse e-mail n'a pas encore été enregistrée.", + phone_not_exist: "Le numéro de téléphone n'a pas encore été enregistré.", + identity_not_exist: "Le compte social n'a pas encore été enregistré.", + identity_already_in_use: 'Le compte social a été enregistré.', + invalid_role_names: 'Les noms de rôles ({{roleNames}}) ne sont pas valides', cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED - sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED - sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED + sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED + sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED - require_password: 'You need to set a password before signing-in.', // UNTRANSLATED - password_exists: 'Your password has been set.', // UNTRANSLATED - require_username: 'You need to set a username before signing-in.', // UNTRANSLATED - username_exists: 'This username is already in use.', // UNTRANSLATED - require_email: 'You need to add an email address before signing-in.', // UNTRANSLATED - email_exists: 'This email is associated with an existing account.', // UNTRANSLATED - require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED - sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED - require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED + password_required_in_profile: 'You need to set a password before signing-in.', // UNTRANSLATED + new_password_required_in_profile: 'You need to set a new password.', // UNTRANSLATED + password_exists_in_profile: 'Password already exists in your profile.', // UNTRANSLATED + username_required_in_profile: 'You need to set a username before signing-in.', // UNTRANSLATED + username_exists_in_profile: 'Username already exists in your profile.', // UNTRANSLATED + email_required_in_profile: 'You need to add an email address before signing-in.', // UNTRANSLATED + email_exists_in_profile: 'Your profile has already associated with an email address.', // UNTRANSLATED + phone_required_in_profile: 'You need to add a phone number before signing-in.', // UNTRANSLATED + phone_exists_in_profile: 'Your profile has already associated with a phone number.', // UNTRANSLATED + email_or_phone_required_in_profile: + 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED + user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, + missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.", @@ -81,6 +85,8 @@ const errors = { unauthorized: "Veuillez vous enregistrer d'abord.", unsupported_prompt_name: "Nom d'invite non supporté.", forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: "Une erreur inattendue s'est produite dans le connecteur. {{errorDescription}}", @@ -105,6 +111,15 @@ const errors = { more_than_one_email: 'Le nombre de connecteurs Email est supérieur à 1.', db_connector_type_mismatch: 'Il y a un connecteur dans la base de donnée qui ne correspond pas au type.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED + invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED + can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", // UNTRANSLATED }, passcode: { phone_email_empty: "Le téléphone et l'email sont vides.", diff --git a/packages/phrases/src/locales/fr/index.ts b/packages/phrases/src/locales/fr/index.ts index 7eaf393f6..5a3541d9f 100644 --- a/packages/phrases/src/locales/fr/index.ts +++ b/packages/phrases/src/locales/fr/index.ts @@ -1,6 +1,6 @@ -import type { LocalPhrase } from '../../types'; -import errors from './errors'; -import translation from './translation'; +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; const fr: LocalPhrase = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/fr/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/fr/translation/admin-console/api-resource-details.ts index 69dd3b4ff..304a54858 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: 'Retour aux ressources API', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', // UNTRANSLATED token_expiration_time_in_seconds: "Temps d'expiration du jeton (en secondes)", token_expiration_time_in_seconds_placeholder: "Entrez le délai d'expiration de votre jeton", delete_description: diff --git a/packages/phrases/src/locales/fr/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/fr/translation/admin-console/api-resources.ts index a73a7e114..6eaf4731b 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/api-resources.ts @@ -6,7 +6,7 @@ const api_resources = { api_name_placeholder: "Entrez votre nom d'API", api_identifier: 'Identifiant API', api_identifier_tip: - "L'identifiant unique de la ressource API. Il doit s'agir d'un URI absolu et ne doit pas comporter de fragment (#). Équivaut au paramètre de ressource dans OAuth 2.0.", + "L'identifiant unique de la ressource API. Il doit s'agir d'un URI absolu et ne doit pas comporter de fragment (#). Équivaut au paramètre de ressource dans OAuth 2.0.", api_resource_created: 'La ressource API {{name}} a été créée avec succès.', api_identifier_placeholder: 'https://votre-identifiant-api/', }; diff --git a/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts b/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts index a9151bbb1..73fb6ec15 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts @@ -1,22 +1,29 @@ const application_details = { back_to_applications: 'Retour aux applications', check_guide: 'Aller voir le guide', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', // UNTRANSLATED advanced_settings: 'Paramètres avancés', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', // UNTRANSLATED application_name: "Nom de l'application", application_name_placeholder: 'Mon App', description: 'Description', description_placeholder: 'Entrez la description de votre application', authorization_endpoint: 'Authorization endpoint', authorization_endpoint_tip: - "Le point de terminaison pour effectuer l'authentification et l'autorisation. Il est utilisé pour l'authentification OpenID Connect.", + "Le point de terminaison pour effectuer l'authentification et l'autorisation. Il est utilisé pour l'authentification OpenID Connect.", application_id: 'App ID', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', // UNTRANSLATED application_secret: 'App Secret', redirect_uri: 'Redirect URI', redirect_uris: 'Redirect URIs', redirect_uri_placeholder: 'https://votre.site.com/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - "L'URI de redirection après la connexion d'un utilisateur (qu'elle soit réussie ou non). Voir OpenID Connect AuthRequest pour plus d'informations.", + "L'URI de redirection après la connexion d'un utilisateur (qu'elle soit réussie ou non). Voir OpenID Connect AuthRequest pour plus d'informations.", post_sign_out_redirect_uri: 'URI de redirection post-signature', post_sign_out_redirect_uris: 'URI de redirection après la signature', post_sign_out_redirect_uri_placeholder: 'https://votre.site.com/home', @@ -25,8 +32,7 @@ const application_details = { cors_allowed_origins: 'Origines CORS autorisées', cors_allowed_origins_placeholder: 'https://votre.site.com', cors_allowed_origins_tip: - "Par défaut, toutes les origines des URI de redirection seront autorisées. En général, aucune action n'est requise pour ce champ.", - add_another: 'Ajouter un autre', + "Par défaut, toutes les origines des URI de redirection seront autorisées. En général, aucune action n'est requise pour ce champ. See the MDN doc for detailed info.", // UNTRANSLATED id_token_expiration: "Expiration du jeton d'identification", refresh_token_expiration: "Rafraîchir l'expiration du jeton", token_endpoint: 'Token Endpoint', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/fr/translation/admin-console/connector-details.ts index f11ada084..8db9ccb95 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: 'Retour à Connecteurs', check_readme: 'Vérifier le README', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', // UNTRANSLATED save_error_empty_config: 'Veuillez entrer la configuration', send: 'Envoyer', send_error_invalid_format: 'Entrée non valide', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts b/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts index 894bb377f..97b43283e 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts @@ -31,6 +31,24 @@ const connectors = { }, guide: { subtitle: 'Un guide étape par étape pour configurer votre connecteur', + connector_setting: 'Connector setting', // UNTRANSLATED + name: 'Connector name', // UNTRANSLATED + name_tip: 'Connector button’s name will display as "Continue with {{Connector Name}}".', // UNTRANSLATED + logo: 'Connector logo URL', // UNTRANSLATED + logo_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_tip: 'The logo image will also display on the connector button.', // UNTRANSLATED + logo_dark: 'Connector logo URL (Dark mode)', // UNTRANSLATED + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_dark_tip: + 'This will be used when opening “Enable dark mode” in the setting of sign in experience.', // UNTRANSLATED + logo_dark_collapse: 'Collapse', // UNTRANSLATED + logo_dark_show: 'Show "Logo for dark mode"', // UNTRANSLATED + target: 'Connector identity target', // UNTRANSLATED + target_tip: 'A unique identifier for the connector.', // UNTRANSLATED + config: 'Enter your JSON here', // UNTRANSLATED + sync_profile: 'Sync profile information from the social provider', // UNTRANSLATED + sync_profile_only_at_register: 'Only sync at register', // UNTRANSLATED + sync_profile_each_sign_in: 'Always sync at each sign-in', // UNTRANSLATED }, platform: { universal: 'Universel', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/general.ts b/packages/phrases/src/locales/fr/translation/admin-console/general.ts index 4686987d5..cf7a76f47 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: 'Sauvegarder', save_changes: 'Sauvegarder les modifications', saved: 'Sauvegardé !', + discard: 'Discard', // UNTRANSLATED loading: 'Chargement...', redirecting: 'Redirection...', add: 'Ajouter', @@ -29,7 +30,7 @@ const general = { copying: 'Copie', copied: 'Copié', required: 'Requis', - add_another: '+ Ajouter un autre', + add_another: 'Ajouter un autre', deletion_confirmation: 'Êtes-vous sûr de vouloir supprimer ce {{title}} ?', settings_nav: 'Paramètres', unsaved_changes_warning: @@ -38,6 +39,9 @@ const general = { stay_on_page: 'Rester sur la page', type_to_search: 'Type to search', // UNTRANSLATED got_it: 'Got it', // UNTRANSLATED + page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED + learn_more: 'Learn more', // UNTRANSLATED + tab_errors: '{{count, number}} errors', // UNTRANSLATED }; export default general; diff --git a/packages/phrases/src/locales/fr/translation/admin-console/index.ts b/packages/phrases/src/locales/fr/translation/admin-console/index.ts index e5384fe80..102cfde03 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: 'Admin Console', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/settings.ts b/packages/phrases/src/locales/fr/translation/admin-console/settings.ts index f18ce705a..a532ef120 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: 'Paramètres', description: 'Gérer les paramètres globaux', - tabs: { - general: 'Général', - }, + settings: 'Paramètres', custom_domain: 'Domaine personnalisé', language: 'Langue', appearance: 'Apparence', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts index 5063d1838..c02e5af1b 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts @@ -70,6 +70,7 @@ const sign_in_exp = { password_auth: 'Password', // UNTRANSLATED verification_code_auth: 'Verification code', // UNTRANSLATED auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED + require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED }, social_sign_in: { title: 'SOCIAL SIGN-IN', // UNTRANSLATED @@ -136,8 +137,8 @@ const sign_in_exp = { '{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED got_it: 'Got It', // UNTRANSLATED }, - authentication: { - title: 'AUTHENTICATION', + advanced_options: { + title: 'OPTIONS AVANCÉES', enable_user_registration: 'Enable user registration', // UNTRANSLATED enable_user_registration_description: 'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', // UNTRANSLATED @@ -146,13 +147,14 @@ const sign_in_exp = { setup_warning: { no_connector: '', no_connector_sms: - 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in.', // UNTRANSLATED + 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_email: - 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in.', // UNTRANSLATED + 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_social: - 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in.', // UNTRANSLATED + 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_added_social_connector: - "Vous avez maintenant configuré quelques connecteurs sociaux. Assurez-vous d'en ajouter quelques-uns à votre expérience de connexion.", + "Vous avez maintenant configuré quelques connecteurs sociaux. Assurez-vous d'en ajouter quelques-uns à votre expérience de connexion.", // UNTRANSLATED + setup_link: 'Set up', }, save_alert: { description: diff --git a/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts b/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts index c9372586f..87d4accf5 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/user-details.ts @@ -17,6 +17,9 @@ const user_details = { new_password: 'Nouveau mot de passe :', }, tab_logs: "Journaux de l'utilisateur", + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', // UNTRANSLATED field_email: 'Email principale', field_phone: 'Téléphone principal', field_username: "Nom d'utilisateur", diff --git a/packages/phrases/src/locales/fr/translation/index.ts b/packages/phrases/src/locales/fr/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/fr/translation/index.ts +++ b/packages/phrases/src/locales/fr/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index becc12735..f1ee88d42 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -31,30 +31,34 @@ const errors = { provider_error: 'OIDC 내부 오류: {{message}}.', }, user: { - username_exists_register: '사용자 이름이 이미 등록되있어요.', - email_exists_register: '이메일이 이미 등록되있어요.', - phone_exists_register: '휴대전화번호가 이미 등록되있어요.', + username_already_in_use: 'This username is already in use.', // UNTRANSLATED + email_already_in_use: 'This email is associated with an existing account.', // UNTRANSLATED + phone_already_in_use: 'This phone number is associated with an existing account.', // UNTRANSLATED invalid_email: '유효하지 않은 이메일이예요.', invalid_phone: '유효하지 않은 휴대전화번호에요', - email_not_exists: '이메일 주소가 아직 등록되지 않았어요.', - phone_not_exists: '휴대전화번호가 아직 등록되지 않았어요.', - identity_not_exists: '소셜 계정이 아직 등록되지 않았어요.', - identity_exists: '소셜 계정이 이미 등록되있어요.', + email_not_exist: '이메일 주소가 아직 등록되지 않았어요.', + phone_not_exist: '휴대전화번호가 아직 등록되지 않았어요.', + identity_not_exist: '소셜 계정이 아직 등록되지 않았어요.', + identity_already_in_use: '소셜 계정이 이미 등록되있어요.', invalid_role_names: '직책 명({{roleNames}})이 유효하지 않아요.', cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED - sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED - sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED + sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED + sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED - require_password: 'You need to set a password before signing-in.', // UNTRANSLATED - password_exists: 'Your password has been set.', // UNTRANSLATED - require_username: 'You need to set a username before signing-in.', // UNTRANSLATED - username_exists: 'This username is already in use.', // UNTRANSLATED - require_email: 'You need to add an email address before signing-in.', // UNTRANSLATED - email_exists: 'This email is associated with an existing account.', // UNTRANSLATED - require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED - sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED - require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED + password_required_in_profile: 'You need to set a password before signing-in.', // UNTRANSLATED + new_password_required_in_profile: 'You need to set a new password.', // UNTRANSLATED + password_exists_in_profile: 'Password already exists in your profile.', // UNTRANSLATED + username_required_in_profile: 'You need to set a username before signing-in.', // UNTRANSLATED + username_exists_in_profile: 'Username already exists in your profile.', // UNTRANSLATED + email_required_in_profile: 'You need to add an email address before signing-in.', // UNTRANSLATED + email_exists_in_profile: 'Your profile has already associated with an email address.', // UNTRANSLATED + phone_required_in_profile: 'You need to add a phone number before signing-in.', // UNTRANSLATED + phone_exists_in_profile: 'Your profile has already associated with a phone number.', // UNTRANSLATED + email_or_phone_required_in_profile: + 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED + user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, + missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.', @@ -75,6 +79,8 @@ const errors = { unauthorized: '로그인을 먼저 해주세요.', unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.', forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: '연동 중에 알 수 없는 오류가 발생했어요. {{errorDescription}}', @@ -97,6 +103,15 @@ const errors = { more_than_one_sms: '연동된 SMS 서비스가 1개 이상이여야 해요.', more_than_one_email: '연동된 이메일 서비스가 1개 이상이여야 해요.', db_connector_type_mismatch: '종류가 일치하지 않은 연동 서비스가 DB에 존재해요.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED + invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED + can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", // UNTRANSLATED }, passcode: { phone_email_empty: '휴대전화번호 그리고 이메일이 비어있어요.', diff --git a/packages/phrases/src/locales/ko/index.ts b/packages/phrases/src/locales/ko/index.ts index a8e49ed0b..9788f8495 100644 --- a/packages/phrases/src/locales/ko/index.ts +++ b/packages/phrases/src/locales/ko/index.ts @@ -1,6 +1,6 @@ -import type { LocalPhrase } from '../../types'; -import errors from './errors'; -import translation from './translation'; +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; const ko: LocalPhrase = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/ko/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/ko/translation/admin-console/api-resource-details.ts index bc4dfb59e..2b7e794de 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: 'API 리소스로 돌아가기', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', // UNTRANSLATED token_expiration_time_in_seconds: '토큰 만료 시간 (초)', token_expiration_time_in_seconds_placeholder: '토큰 만료 시간을 입력해주세요', delete_description: diff --git a/packages/phrases/src/locales/ko/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/ko/translation/admin-console/api-resources.ts index c94596fda..8ed1d3de0 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/api-resources.ts @@ -6,7 +6,7 @@ const api_resources = { api_name_placeholder: 'API 이름 입력', api_identifier: 'API 식별자', api_identifier_tip: - 'API 리소스에 대한 유일한 식별자예요. 반드시, 절대적인 URI 이여야 하며, 프래그먼트 (#) 요소가 없어야해요. OAuth 2.0의 리소스 파라미터와 동일해요.', + 'The unique identifier to the API resource. It must be an absolute URI and has no fragment (#) component. Equals to the resource parameter in OAuth 2.0.', // UNTRANSLATED api_resource_created: '{{name}} API 리소스가 성공적으로 생성되었어요.', api_identifier_placeholder: 'https://your-api-identifier/', }; diff --git a/packages/phrases/src/locales/ko/translation/admin-console/application-details.ts b/packages/phrases/src/locales/ko/translation/admin-console/application-details.ts index 1e6e2f5da..dd92631c9 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/application-details.ts @@ -1,22 +1,29 @@ const application_details = { back_to_applications: '어플리케이션으로 돌아가기', check_guide: '가이드 확인', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', // UNTRANSLATED advanced_settings: '고급 설정', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', // UNTRANSLATED application_name: '어플리케이션 이름', application_name_placeholder: '나의 앱', description: '설명', description_placeholder: '어플리케이션 설명을 적어주세요.', authorization_endpoint: '인증 End-Point', authorization_endpoint_tip: - '인증 및 권한 부여를 진행할 End-Point예요. OpenID Connect 인증에서 사용되던 값 이에요.', + "The endpoint to perform authentication and authorization. It's used for OpenID Connect Authentication.", // UNTRANSLATED application_id: 'App ID', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', // UNTRANSLATED application_secret: 'App Secret', redirect_uri: 'Redirect URI', redirect_uris: 'Redirect URIs', redirect_uri_placeholder: 'https://your.website.com/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - '사용자 로그인 이후, 리다이렉트 될 URI 경로예요. 더욱 자세한 정보는 OpenID Connect AuthRequest를 참고해주세요.', + '사용자 로그인 이후, 리다이렉트 될 URI 경로예요. 더욱 자세한 정보는 OpenID Connect AuthRequest를 참고해주세요.', post_sign_out_redirect_uri: '로그아웃 이후 Redirect URI', post_sign_out_redirect_uris: '로그아웃 이후 Redirect URIs', post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home', @@ -25,8 +32,7 @@ const application_details = { cors_allowed_origins: 'CORS Allow Origins', cors_allowed_origins_placeholder: 'https://your.website.com', cors_allowed_origins_tip: - '기본으로 모든 리다이렉트의 오리진들은 허용되요. 대체적으로 이 값을 건들 필요는 없어요.', - add_another: '새로 추가', + '기본으로 모든 리다이렉트의 오리진들은 허용되요. 대체적으로 이 값을 건들 필요는 없어요. See the MDN doc for detailed info.', // UNTRANSLATED id_token_expiration: 'ID 토큰 만료', refresh_token_expiration: 'Refresh 토큰 만료', token_endpoint: '토큰 End-Point', diff --git a/packages/phrases/src/locales/ko/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/ko/translation/admin-console/connector-details.ts index 0d0f07949..f755164ca 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: '연동으로 돌아가기', check_readme: 'README 확인', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', // UNTRANSLATED save_error_empty_config: '설정을 입력해주세요.', send: '보내기', send_error_invalid_format: '유효하지 않은 입력', diff --git a/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts b/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts index f6936ee0e..2108727e9 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts @@ -30,6 +30,24 @@ const connectors = { }, guide: { subtitle: '단계별 가이드를 따라, 연동해주세요.', + connector_setting: 'Connector setting', // UNTRANSLATED + name: 'Connector name', // UNTRANSLATED + name_tip: 'Connector button’s name will display as "Continue with {{Connector Name}}".', // UNTRANSLATED + logo: 'Connector logo URL', // UNTRANSLATED + logo_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_tip: 'The logo image will also display on the connector button.', // UNTRANSLATED + logo_dark: 'Connector logo URL (Dark mode)', // UNTRANSLATED + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_dark_tip: + 'This will be used when opening “Enable dark mode” in the setting of sign in experience.', // UNTRANSLATED + logo_dark_collapse: 'Collapse', // UNTRANSLATED + logo_dark_show: 'Show "Logo for dark mode"', // UNTRANSLATED + target: 'Connector identity target', // UNTRANSLATED + target_tip: 'A unique identifier for the connector.', // UNTRANSLATED + config: 'Enter your JSON here', // UNTRANSLATED + sync_profile: 'Sync profile information from the social provider', // UNTRANSLATED + sync_profile_only_at_register: 'Only sync at register', // UNTRANSLATED + sync_profile_each_sign_in: 'Always sync at each sign-in', // UNTRANSLATED }, platform: { universal: 'Universal', diff --git a/packages/phrases/src/locales/ko/translation/admin-console/general.ts b/packages/phrases/src/locales/ko/translation/admin-console/general.ts index 3d88f40a1..a0917946c 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: '저장', save_changes: '변경 내용 저장', saved: '저장됨!', + discard: 'Discard', // UNTRANSLATED loading: '로딩 중...', redirecting: '리다이렉팅 중...', add: '추가', @@ -29,7 +30,7 @@ const general = { copying: '복사 중', copied: '복사됨', required: '필수', - add_another: '+ 새로 추가', + add_another: '새로 추가', deletion_confirmation: '정말로 {{title}}을/를 삭제할까요?', settings_nav: '설정', unsaved_changes_warning: '수정된 내용이 있어요. 정말로 현재 페이지를 벗어날까요?', @@ -37,6 +38,9 @@ const general = { stay_on_page: '페이지 유지하기', type_to_search: 'Type to search', // UNTRANSLATED got_it: 'Got it', // UNTRANSLATED + page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED + learn_more: 'Learn more', // UNTRANSLATED + tab_errors: '{{count, number}} errors', // UNTRANSLATED }; export default general; diff --git a/packages/phrases/src/locales/ko/translation/admin-console/index.ts b/packages/phrases/src/locales/ko/translation/admin-console/index.ts index 82dce9a5c..a1641d313 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: '관리자 Console', diff --git a/packages/phrases/src/locales/ko/translation/admin-console/settings.ts b/packages/phrases/src/locales/ko/translation/admin-console/settings.ts index 4d00e0010..984cf5b69 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: '설정', description: '전체 설정을 관리해보세요.', - tabs: { - general: '일반', - }, + settings: '설정', custom_domain: '커스텀 도메인', language: '언어', appearance: '모습', diff --git a/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts index d6612a723..ce9636448 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts @@ -65,6 +65,7 @@ const sign_in_exp = { password_auth: 'Password', // UNTRANSLATED verification_code_auth: 'Verification code', // UNTRANSLATED auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED + require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED }, social_sign_in: { title: 'SOCIAL SIGN-IN', // UNTRANSLATED @@ -131,8 +132,8 @@ const sign_in_exp = { '{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED got_it: 'Got It', // UNTRANSLATED }, - authentication: { - title: 'AUTHENTICATION', + advanced_options: { + title: '고급 옵션', enable_user_registration: 'Enable user registration', // UNTRANSLATED enable_user_registration_description: 'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', // UNTRANSLATED @@ -141,13 +142,14 @@ const sign_in_exp = { setup_warning: { no_connector: '', no_connector_sms: - 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in.', // UNTRANSLATED + 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_email: - 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in.', // UNTRANSLATED + 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_social: - 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in.', // UNTRANSLATED + 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_added_social_connector: '보다 많은 소셜 연동들을 설정하여, 고객에게 보다 나은 경험을 제공해보세요.', + setup_link: 'Set up', }, save_alert: { description: diff --git a/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts b/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts index 36385ae2b..49d04e8d5 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/user-details.ts @@ -15,6 +15,9 @@ const user_details = { new_password: '새로운 비밀번호:', }, tab_logs: '사용자 기록', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', // UNTRANSLATED field_email: '메인 이메일', field_phone: '메인 휴대전화번호', field_username: '사용자 이름', diff --git a/packages/phrases/src/locales/ko/translation/index.ts b/packages/phrases/src/locales/ko/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/ko/translation/index.ts +++ b/packages/phrases/src/locales/ko/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/locales/pt-br/errors.ts b/packages/phrases/src/locales/pt-br/errors.ts new file mode 100644 index 000000000..71439ef8a --- /dev/null +++ b/packages/phrases/src/locales/pt-br/errors.ts @@ -0,0 +1,176 @@ +const errors = { + auth: { + authorization_header_missing: 'O cabeçalho de autorização está ausente.', + authorization_token_type_not_supported: 'O tipo de autorização não é suportado.', + unauthorized: 'Não autorizado. Verifique as credenciais e seu escopo.', + forbidden: 'Proibido. Verifique suas funções e permissões de usuário.', + expected_role_not_found: + 'Regra esperada não encontrada. Verifique suas regras e permissões de usuário.', + jwt_sub_missing: '`sub` ausente no JWT.', + require_re_authentication: 'A reautenticação é necessária para executar uma ação protegida.', + }, + guard: { + invalid_input: 'A solicitação {{type}} é inválida.', + invalid_pagination: 'O valor de paginação da solicitação é inválido.', + }, + oidc: { + aborted: 'A interação abortada pelo end-user', + invalid_scope: 'Escopo {{scope}} não é suportado.', + invalid_scope_plural: 'Escopo {{scopes}} não são suportados.', + invalid_token: 'Token inválido.', + invalid_client_metadata: 'Metadados de cliente inválidos.', + insufficient_scope: 'Escopo solicitado ausente {{scopes}} do token de acesso.', + invalid_request: 'A solicitação é inválida.', + invalid_grant: 'A solicitação de concessão é inválida.', + invalid_redirect_uri: + '`redirect_uri` não correspondeu a nenhum `redirect_uris` registrado do cliente.', + access_denied: 'Acesso negado.', + invalid_target: 'Indicador de recurso inválido.', + unsupported_grant_type: '`grant_type` não suportado.', + unsupported_response_mode: '`response_mode` não suportado.', + unsupported_response_type: '`response_type` não suportado.', + provider_error: 'Erro interno OIDC: {{message}}.', + }, + user: { + username_already_in_use: 'Este nome de usuário já está em uso.', + email_already_in_use: 'Este e-mail está associado a uma conta existente.', + phone_already_in_use: 'Este número de telefone está associado a uma conta existente.', + invalid_email: 'Endereço de e-mail inválido.', + invalid_phone: 'Número de telefone inválido.', + email_not_exist: 'O endereço de e-mail ainda não foi registrado.', + phone_not_exist: 'O número de telefone ainda não foi registrado.', + identity_not_exist: 'A conta social ainda não foi registrada.', + identity_already_in_use: 'A conta social foi associada a uma conta existente.', + invalid_role_names: 'Nomes de regra ({{roleNames}}) não são válidos.', + cannot_delete_self: 'Você não pode excluir a si mesmo.', + sign_up_method_not_enabled: 'Este método de inscrição não está ativado', + sign_in_method_not_enabled: 'Este método de login não está habilitado.', + same_password: 'A nova senha não pode ser igual à senha antiga.', + password_required_in_profile: 'Você precisa definir uma senha antes de entrar.', + new_password_required_in_profile: 'Você precisa definir uma nova senha.', + password_exists_in_profile: 'A senha já existe em seu perfil.', + username_required_in_profile: 'Você precisa definir um nome de usuário antes de entrar.', + username_exists_in_profile: 'O nome de usuário já existe em seu perfil.', + email_required_in_profile: 'Você precisa adicionar um endereço de e-mail antes de fazer login.', + email_exists_in_profile: 'Seu perfil já foi associado a um endereço de e-mail.', + phone_required_in_profile: 'Você precisa adicionar um número de telefone antes de fazer login.', + phone_exists_in_profile: 'Seu perfil já foi associado a um número de telefone.', + email_or_phone_required_in_profile: + 'Você precisa adicionar um endereço de e-mail ou número de telefone antes de fazer login.', + suspended: 'Esta conta está suspensa.', + user_not_exist: 'O usuário com {{ identity }} não existe', + missing_profile: 'Você precisa fornecer informações adicionais antes de fazer login.', + }, + password: { + unsupported_encryption_method: 'O método de criptografia {{name}} não é suportado.', + pepper_not_found: 'Password pepper não encontrada. Por favor, verifique seus envs principais.', + }, + session: { + not_found: 'Sessão não encontrada. Volte e faça login novamente.', + invalid_credentials: 'Credenciais inválidas. Verifique sua entrada.', + invalid_sign_in_method: 'O método de login atual não está disponível.', + invalid_connector_id: + 'Não foi possível encontrar o conector disponível com id {{connectorId}}.', + insufficient_info: 'Informações de login insuficientes.', + connector_id_mismatch: 'O connectorId é incompatível com o registro da sessão.', + connector_session_not_found: 'Sessão do conector não encontrada. Volte e faça login novamente.', + verification_session_not_found: + 'A verificação não foi bem-sucedida. Reinicie o fluxo de verificação e tente novamente.', + verification_expired: + 'A conexão expirou. Verifique novamente para garantir a segurança da sua conta.', + unauthorized: 'Faça login primeiro.', + unsupported_prompt_name: 'Prompt name incompatível.', + forgot_password_not_enabled: 'Esqueceu a senha não está ativado.', + verification_failed: + 'A verificação não foi bem-sucedida. Reinicie o fluxo de verificação e tente novamente.', + }, + connector: { + general: 'Ocorreu um erro inesperado no conector.{{errorDescription}}', + not_found: 'Não foi possível encontrar nenhum conector disponível para o tipo: {{type}}.', + not_enabled: 'O conector não está ativado.', + invalid_metadata: 'Os metadados do conector são inválidos.', + invalid_config_guard: 'A proteção de configuração do conector é inválida.', + unexpected_type: 'O tipo do conector é inesperado.', + invalid_request_parameters: 'A solicitação está com parâmetro(s) de entrada incorreto(s).', + insufficient_request_parameters: 'A solicitação pode perder alguns parâmetros de entrada.', + invalid_config: 'A configuração do conector é inválida.', + invalid_response: 'A resposta do conector é inválida.', + template_not_found: 'Não foi possível encontrar o modelo correto na configuração do conector.', + not_implemented: '{{method}}: ainda não foi implementado.', + social_invalid_access_token: 'O token de acesso do conector é inválido.', + invalid_auth_code: 'O código de autenticação do conector é inválido.', + social_invalid_id_token: 'O token de id do conector é inválido.', + authorization_failed: 'O processo de autorização do usuário não foi bem-sucedido.', + social_auth_code_invalid: + 'Não foi possível obter o token de acesso, verifique o código de autorização.', + more_than_one_sms: 'O número de conectores SMS é maior que 1.', + more_than_one_email: 'O número de conectores de e-mail é maior que 1.', + db_connector_type_mismatch: 'Existe um conector no banco de dados que não corresponde ao tipo.', + not_found_with_connector_id: + 'Não é possível encontrar o conector com o ID de conector padrão fornecido.', + multiple_instances_not_supported: + 'Não é possível criar várias instâncias com conector padrão escolhido.', + invalid_type_for_syncing_profile: + 'Você só pode sincronizar o perfil do usuário com conectores sociais.', + can_not_modify_target: 'O destino do conector não pode ser modificado.', + multiple_target_with_same_platform: + 'Você não pode ter vários conectores sociais com o mesmo destino e plataforma.', + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", // UNTRANSLATED + }, + passcode: { + phone_email_empty: 'Telefone e e-mail estão vazios.', + not_found: 'Senha não encontrada. Por favor, envie a senha primeiro.', + phone_mismatch: 'Incompatibilidade de telefone. Solicite uma nova senha.', + email_mismatch: 'Incompatibilidade de e-mail. Solicite uma nova senha.', + code_mismatch: 'Senha inválida.', + expired: 'A senha expirou. Solicite uma nova senha.', + exceed_max_try: 'Limite de verificação de senha excedida. Solicite uma nova senha.', + }, + sign_in_experiences: { + empty_content_url_of_terms_of_use: + 'URL de conteúdo "Termos de uso" vazia. Adicione o URL do conteúdo se "Termos de uso" estiver ativado.', + empty_logo: 'Insira o URL do seu logotipo', + empty_slogan: + 'Slogan de marca vazio. Adicione um slogan de marca se um estilo de IU contendo o slogan for selecionado.', + empty_social_connectors: + 'Conectores sociais vazios. Adicione conectores sociais ativados quando o método de login social estiver ativado.', + enabled_connector_not_found: 'Conector {{type}} ativado não encontrado.', + not_one_and_only_one_primary_sign_in_method: + 'Deve haver um método de login principal. Verifique sua entrada.', + username_requires_password: + 'Deve permitir definir uma senha para o identificador de inscrição do nome de usuário.', + passwordless_requires_verify: + 'Deve ativar a verificação do identificador de inscrição de e-mail/telefone.', + miss_sign_up_identifier_in_sign_in: + 'Os métodos de login devem conter o identificador de inscrição.', + password_sign_in_must_be_enabled: + 'O login com senha deve ser ativado quando definir uma senha é necessária na inscrição.', + code_sign_in_must_be_enabled: + 'O login do código de verificação deve ser ativado quando definir uma senha não é necessária na inscrição.', + unsupported_default_language: 'Este idioma - {{language}} não é suportado no momento.', + at_least_one_authentication_factor: 'Você deve selecionar pelo menos um fator de autenticação.', + }, + localization: { + cannot_delete_default_language: + '{{languageTag}} está definido como seu idioma padrão e não pode ser excluído.', + invalid_translation_structure: + 'Esquemas de dados inválidos. Verifique sua entrada e tente novamente.', + }, + swagger: { + invalid_zod_type: 'Zod type inválido. Verifique a configuração do protetor de rota.', + not_supported_zod_type_for_params: + 'Zod type não suportado para os parâmetros. Verifique a configuração do protetor de rota.', + }, + entity: { + create_failed: 'Falha ao criar {{name}}.', + not_exists: 'O {{name}} não existe.', + not_exists_with_id: 'O {{name}} com ID `{{id}}` não existe.', + not_found: 'O recurso não existe.', + }, + log: { + invalid_type: 'O tipo de registro é inválido.', + }, +}; + +export default errors; diff --git a/packages/phrases/src/locales/pt-br/index.ts b/packages/phrases/src/locales/pt-br/index.ts new file mode 100644 index 000000000..2c087c53d --- /dev/null +++ b/packages/phrases/src/locales/pt-br/index.ts @@ -0,0 +1,10 @@ +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; + +const ptBR: LocalPhrase = Object.freeze({ + translation, + errors, +}); + +export default ptBR; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/api-resource-details.ts new file mode 100644 index 000000000..5c6fa4b90 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/api-resource-details.ts @@ -0,0 +1,14 @@ +const api_resource_details = { + back_to_api_resources: 'Voltar para os recursos da API', + settings: 'Configurações', + settings_description: + 'Os recursos da API, também conhecidos como Indicadores de recursos, indicam os serviços ou recursos de destino a serem solicitados, geralmente uma variável de formato de URI que representa a identidade do recurso.', + token_expiration_time_in_seconds: 'Tempo de expiração do token (em segundos)', + token_expiration_time_in_seconds_placeholder: 'Digite o tempo de expiração do seu token', + delete_description: + 'Essa ação não pode ser desfeita. Isso excluirá permanentemente o recurso da API. Insira o nome do recurso de API {{name}} para confirmar.', + enter_your_api_resource_name: 'Digite o nome do recurso da API', + api_resource_deleted: 'O recurso da API {{name}} foi excluído com sucesso', +}; + +export default api_resource_details; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/api-resources.ts new file mode 100644 index 000000000..ffd84a22f --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/api-resources.ts @@ -0,0 +1,14 @@ +const api_resources = { + title: 'Recursos da API', + subtitle: 'Defina APIs que você pode consumir de seus aplicativos autorizados', + create: 'Criar recurso de API', + api_name: 'Nome da API', + api_name_placeholder: 'Digite o nome da sua API', + api_identifier: 'Identificador de API', + api_identifier_tip: + 'O identificador exclusivo para o recurso da API. Deve ser um URI absoluto e não tem nenhum componente de fragmento (#). Igual ao parâmetro de recurso em OAuth 2.0.', + api_resource_created: 'O recurso API {{name}} foi criado com sucesso', + api_identifier_placeholder: 'https://your-api-identifier/', +}; + +export default api_resources; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/application-details.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/application-details.ts new file mode 100644 index 000000000..e84b034c7 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/application-details.ts @@ -0,0 +1,50 @@ +const application_details = { + back_to_applications: 'Voltar para Aplicativos', + check_guide: 'Visualize o guia', + settings: 'Configurações', + settings_description: + 'Os aplicativos são usados para identificar seus aplicativos no Logto para OIDC, experiência de login, logs de auditoria, etc.', + advanced_settings: 'Configurações avançadas', + advanced_settings_description: + 'As configurações avançadas incluem termos relacionados ao OIDC. Você pode conferir o Token Endpoint para obter mais informações.', + application_name: 'Nome do aplicatio', + application_name_placeholder: 'My App', + description: 'Descrição', + description_placeholder: 'Digite a descrição do seu aplicativo', + authorization_endpoint: 'Endpoint de autorização', + authorization_endpoint_tip: + 'O endpoint para executar autenticação e autorização. É usado para autenticação OpenID Connect.', + application_id: 'ID do aplicativo', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', // UNTRANSLATED + application_secret: 'Secret do aplicativo', + redirect_uri: 'URI de redirecionamento', + redirect_uris: 'URIs de redirecionamento', + redirect_uri_placeholder: 'https://your.website.com/app', + redirect_uri_placeholder_native: 'io.logto://callback', + redirect_uri_tip: + 'O URI é redirecionado após o login do usuário (seja bem-sucedido ou não). Consulte OpenID Connect AuthRequest para obter mais informações.', + post_sign_out_redirect_uri: 'URI de redirecionamento Post Sign-out', + post_sign_out_redirect_uris: 'URIs de redirecionamento Post Sign-out', + post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home', + post_sign_out_redirect_uri_tip: + 'O URI é redirecionado após a saída do usuário (opcional). Pode não ter efeito prático em alguns tipos de aplicativos.', + cors_allowed_origins: 'Origens permitidas pelo CORS', + cors_allowed_origins_placeholder: 'https://your.website.com', + cors_allowed_origins_tip: + 'Por padrão, todas as origens de URIs de redirecionamento serão permitidas. Normalmente, nenhuma ação é necessária para este campo. See the MDN doc for detailed info.', // UNTRANSLATED + id_token_expiration: 'Expiração do token de ID', + refresh_token_expiration: 'Expiração Refresh Token', + token_endpoint: 'Token Endpoint', + user_info_endpoint: 'Userinfo endpoint', + enable_admin_access: 'Ativar acesso de administrador', + enable_admin_access_label: + 'Ative ou desative o acesso à API de gerenciamento. Uma vez ativado, você pode usar tokens de acesso para chamar a API de gerenciamento em nome deste aplicativo.', + delete_description: + 'Essa ação não pode ser desfeita. Isso excluirá permanentemente o aplicativo. Insira o nome do aplicativo {{name}} para confirmar.', + enter_your_application_name: 'Digite o nome do seu aplicativo', + application_deleted: 'O aplicativo {{name}} foi excluído com sucesso', + redirect_uri_required: 'Você deve inserir pelo menos um URI de redirecionamento', +}; + +export default application_details; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/applications.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/applications.ts new file mode 100644 index 000000000..90c1c3b29 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/applications.ts @@ -0,0 +1,50 @@ +const applications = { + title: 'Aplicativos', + subtitle: + 'Configure um aplicativo móvel, single page ou tradicional para usar o Logto para autenticação', + create: 'Criar aplicativo', + application_name: 'Nome do Aplicativo', + application_name_placeholder: 'Meu aplicativo', + application_description: 'Descrição do aplicativo', + application_description_placeholder: 'Digite a descrição do seu aplicativo', + select_application_type: 'Selecione um tipo de aplicativo', + no_application_type_selected: 'Você ainda não selecionou nenhum tipo de aplicativo', + application_created: + 'O aplicativo {{name}} foi criado com sucesso! \nAgora conclua as configurações do aplicativo.', + app_id: 'ID do aplicativo', + type: { + native: { + title: 'Native App', + subtitle: 'Um aplicativo executado em um ambiente nativo', + description: 'Ex: iOS app, Android app', + }, + spa: { + title: 'Single Page App', + subtitle: + 'Um aplicativo que é executado em um navegador da Web e atualiza dinamicamente os dados no local', + description: 'Ex: React DOM app, Vue app', + }, + traditional: { + title: 'Traditional Web', + subtitle: 'Um aplicativo que renderiza e atualiza páginas apenas pelo servidor da web', + description: 'Ex: Next.js, PHP', + }, + machine_to_machine: { + title: 'Machine to Machine', + subtitle: 'Um aplicativo (geralmente um serviço) que fala diretamente com os recursos', + description: 'Ex: Backend service', + }, + }, + guide: { + get_sample_file: 'Obter amostra', + header_description: + 'Siga um guia passo a passo para integrar seu aplicativo ou clique no botão direito para obter nosso projeto de amostra', + title: 'O aplicativo foi criado com sucesso', + subtitle: + 'Agora siga as etapas abaixo para concluir as configurações do aplicativo. Selecione o tipo de SDK para continuar.', + description_by_sdk: + 'Este guia de início rápido demonstra como integrar o Logto ao aplicativo {{sdk}}', + }, +}; + +export default applications; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/connector-details.ts new file mode 100644 index 000000000..67b0f408d --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/connector-details.ts @@ -0,0 +1,28 @@ +const connector_details = { + back_to_connectors: 'Voltar para Conectores', + check_readme: 'Visualize o README', + settings: 'Configurações', + settings_description: + 'Os conectores desempenham um papel crítico no Logto. Com a ajuda deles, a Logto permite que os usuários finais usem o registro ou login sem senha e os recursos de login com contas sociais.', + save_error_empty_config: 'Por favor insira a configuração', + send: 'Enviar', + send_error_invalid_format: 'Campo inválido', + edit_config_label: 'Digite seu json aqui', + test_email_sender: 'Teste seu conector de e-mail', + test_sms_sender: 'Teste seu conector SMS', + test_email_placeholder: 'Insira um endereço de e-mail de teste', + test_sms_placeholder: 'Digite um número de telefone de teste', + test_message_sent: 'Mensagem de teste enviada!', + test_sender_description: + 'Você receberá uma mensagem se seu json estiver configurado corretamente', + options_change_email: 'Alterar conector de e-mail', + options_change_sms: 'Alterar conector de SMS', + connector_deleted: 'O conector foi excluído com sucesso', + type_email: 'Conector de e-mail', + type_sms: 'Conector de SMS', + type_social: 'Conector social', + in_use_deletion_description: + 'Este conector está em uso em sua experiência de entrada. Ao excluir, a experiência de login será excluída nas configurações da experiência de login.', +}; + +export default connector_details; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts new file mode 100644 index 000000000..54ff532b7 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts @@ -0,0 +1,63 @@ +const connectors = { + title: 'Conectores', + subtitle: 'Configure conectores para habilitar a experiência de login social e sem senha', + create: 'Adicionar conector social', + config_sie_notice: + 'Você configurou os conectores. Certifique-se de configurá-lo em {{link}}.', + config_sie_link_text: 'experiência de login', + tab_email_sms: 'Conectores de e-mail e SMS', + tab_social: 'Conectores sociais', + connector_name: 'Nome do conector', + connector_type: 'Tipo', + connector_status: 'Experiência de login', + connector_status_in_use: 'Em uso', + connector_status_not_in_use: 'Fora de uso', + not_in_use_tip: { + content: + 'Fora de uso significa que sua experiência de login não usou esse método de login. {{link}} para adicionar este método de login. ', + go_to_sie: 'Vá para a experiência de login', + }, + social_connector_eg: 'Ex: Google, Facebook, Github', + save_and_done: 'Salvar e completar', + type: { + email: 'Conector de e-mail', + sms: 'Conector de SMS', + social: 'Conector social', + }, + setup_title: { + email: 'Configurar conector de e-mail', + sms: 'Configurar conector SMS', + social: 'Adicionar conector social', + }, + guide: { + subtitle: 'Um guia passo a passo para configurar seu conector', + connector_setting: 'Configuração do conector', + name: 'Nome do conector', + name_tip: 'O nome do botão do conector será exibido como "Continue com {{Connector Name}}".', + logo: 'URL do logotipo do conector', + logo_placeholder: 'https://your.cdn.domain/logo.png', + logo_tip: 'A imagem do logotipo também será exibida no botão do conector.', + logo_dark: 'URL do logotipo do conector (modo escuro)', + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', + logo_dark_tip: + 'Isso será usado ao abrir "Ativar modo escuro" na configuração da experiência de login.', + logo_dark_collapse: 'Collapse', + logo_dark_show: 'Mostrar "Logo para modo escuro"', + target: 'Destino da identidade do conector', + target_tip: 'Um identificador exclusivo para o conector.', + config: 'Digite seu JSON aqui', + sync_profile: 'Sincronizar informações de perfil do provedor social', + sync_profile_only_at_register: 'Sincronizar apenas no registro', + sync_profile_each_sign_in: 'Sempre sincronizar a cada login', + }, + platform: { + universal: 'Universal', + web: 'Web', + native: 'Native', + }, + add_multi_platform: ' suporta várias plataformas, selecione uma plataforma para continuar', + drawer_title: 'Guia do Conector', + drawer_subtitle: 'Siga as instruções para integrar seu conector', +}; + +export default connectors; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/contact.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/contact.ts new file mode 100644 index 000000000..b6bd419c9 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/contact.ts @@ -0,0 +1,22 @@ +const contact = { + title: 'Contate-nos', + description: + 'Junte-se à nossa comunidade para fornecer feedback, pedir ajuda e compartilhar suas ideias com outros desenvolvedores', + discord: { + title: 'Canal do Discord', + description: 'Junte-se ao nosso canal público para conversar com outros desenvolvedores', + button: 'Entrar', + }, + github: { + title: 'GitHub', + description: 'Crie uma issue e envie no GitHub', + button: 'Abrir', + }, + email: { + title: 'Enviar email', + description: 'Envie-nos um e-mail para mais informações e ajuda', + button: 'Enviar', + }, +}; + +export default contact; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/dashboard.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/dashboard.ts new file mode 100644 index 000000000..a44937835 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/dashboard.ts @@ -0,0 +1,22 @@ +const dashboard = { + title: 'Painel', + description: 'Obtenha uma visão geral sobre o desempenho do seu aplicativo', + total_users: 'Total de usuários', + total_users_tip: 'Total de usuários', + new_users_today: 'Novos usuários hoje', + new_users_today_tip: 'O número de novos usuários registrados em seus aplicativos hoje', + new_users_7_days: 'Novos usuários nos últimos 7 dias', + new_users_7_days_tip: + 'O número de novos usuários registrados em seus aplicativos nos últimos 7 dias', + daily_active_users: 'Usuários ativos diariamente', + daily_active_users_tip: + 'O número de tokens trocados por usuários únicos em seus aplicativos hoje', + weekly_active_users: 'Usuários ativos semanalmente', + weekly_active_users_tip: + 'O número de tokens trocados por usuários únicos em seus aplicativos nos últimos 7 dias', + monthly_active_users: 'Usuários ativos mensais', + monthly_active_users_tip: + 'O número de tokens trocados por usuários únicos em seus aplicativos nos últimos 30 dias', +}; + +export default dashboard; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts new file mode 100644 index 000000000..a09d846d1 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/errors.ts @@ -0,0 +1,21 @@ +const errors = { + something_went_wrong: 'Ops! Algo deu errado.', + page_not_found: 'Página não encontrada', + unknown_server_error: 'Ocorreu um erro desconhecido no servidor', + empty: 'Sem dados', + missing_total_number: 'Não foi possível encontrar Total-Number nos cabeçalhos de resposta', + invalid_uri_format: 'Formato de URI inválido', + invalid_origin_format: 'Formato de origem de URI inválido', + invalid_json_format: 'Formato JSON inválido', + invalid_error_message_format: 'O formato da mensagem de erro é inválido.', + required_field_missing: 'Por favor, insira {{field}}', + required_field_missing_plural: 'Você deve inserir pelo menos um {{field}}', + more_details: 'Mais detalhes', + username_pattern_error: + 'O nome de usuário deve conter apenas letras, números ou sublinhado e não deve começar com um número.', + password_pattern_error: 'A senha requer um mínimo de 6 caracteres', + insecure_contexts: 'Contextos inseguros (não-HTTPS) não são suportados.', + unexpected_error: 'Um erro inesperado ocorreu', +}; + +export default errors; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/general.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/general.ts new file mode 100644 index 000000000..a640d2e5f --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/general.ts @@ -0,0 +1,47 @@ +const general = { + placeholder: 'Placeholder', + skip: 'Pular', + next: 'Próximo', + retry: 'Tente novamente', + done: 'Feito', + search: 'Buscar', + search_placeholder: 'Buscar', + clear_result: 'Limpar resultados', + save: 'Salvar', + save_changes: 'Salvar alterações', + saved: 'Salvou!', + discard: 'Descartar', + loading: 'Carregando...', + redirecting: 'Redirecionando...', + add: 'Adicionar', + added: 'Adicionado', + cancel: 'Cancelar', + confirm: 'Confirme', + check_out: 'Visualizar', + create: 'Criar', + set_up: 'Configurar', + customize: 'Customizar', + enable: 'Habilitar', + reminder: 'Lembrete', + delete: 'Excluir', + more_options: 'MAIS OPÇÕES', + close: 'Fechar', + copy: 'Copiar', + copying: 'Copiando', + copied: 'Copiado', + required: 'Obrigatório', + add_another: 'Adicionar outro', + deletion_confirmation: 'Tem certeza de que deseja excluir este {{title}}?', + settings_nav: 'Configurações', + unsaved_changes_warning: + 'Você fez algumas alterações. Tem certeza de que deseja sair desta página?', + leave_page: 'Deixar página', + stay_on_page: 'Ficar na página', + type_to_search: 'Digite para pesquisar', + got_it: 'Entendi', + page_info: '{{min, number}}-{{max, number}} de {{total, number}}', + learn_more: 'Saber mais', + tab_errors: '{{count, number}} erros', +}; + +export default general; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/get-started.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/get-started.ts new file mode 100644 index 000000000..84636ce0a --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/get-started.ts @@ -0,0 +1,28 @@ +const get_started = { + progress: 'Rrimeiros passos: {{completed}}/{{total}}', + progress_dropdown_title: 'Algumas coisas que você pode fazer...', + title: 'Como você deseja começar a usar o Logto?', + subtitle_part1: 'Algumas coisas que você pode fazer para utilizar rapidamente o Logto', + subtitle_part2: 'Já finalizei os passos. ', + hide_this: 'Esconder isso', + confirm_message: + 'Tem certeza de que deseja ocultar esta página? Essa ação não pode ser desfeita.', + card1_title: 'Confira a demonstração', + card1_subtitle: 'Experimente a experiência de Logto agora para ver como funciona', + card2_title: 'Crie e integre o primeiro aplicativo', + card2_subtitle: + 'Configure um aplicativo móvel, single page ou tradicional para usar o Logto para autenticação', + card3_title: 'Personalize a experiência de login', + card3_subtitle: + 'Personalize a interface do usuário de login para corresponder à sua marca e visualize em tempo real', + card4_title: 'Configurar SMS e conector de e-mail', + card4_subtitle: + 'Experimente o login sem senha com número de telefone ou e-mail para permitir uma experiência do cliente segura e sem atrito', + card5_title: 'Adicionar um conector social', + card5_subtitle: + 'Permita que seus clientes façam login em seu aplicativo com as identidades sociais em um clique', + card6_title: 'Leituras adicionais', + card6_subtitle: 'Confira nossos documentos de passo a passo baseados em cenários', +}; + +export default get_started; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/index.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/index.ts new file mode 100644 index 000000000..4b390643b --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/index.ts @@ -0,0 +1,52 @@ +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; + +const admin_console = { + title: 'Admin Console', + sign_out: 'Sair', + profile: 'Perfil', + admin_user: 'Administrador', + system_app: 'Sistema', + general, + errors, + tab_sections, + tabs, + applications, + application_details, + api_resources, + api_resource_details, + connectors, + connector_details, + get_started, + users, + user_details, + contact, + sign_in_exp, + settings, + dashboard, + logs, + log_details, + session_expired, + welcome, +}; + +export default admin_console; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/log-details.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/log-details.ts new file mode 100644 index 000000000..a5f250808 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/log-details.ts @@ -0,0 +1,17 @@ +const log_details = { + back_to_logs: 'Voltar aos logs', + back_to_user: 'Voltar para {{name}}', + success: 'Sucesso', + failed: 'Falhou', + event_type: 'Tipo de evento', + application: 'Aplicativo', + ip_address: 'Endereço de IP', + user: 'Usuário', + log_id: 'Log ID', + time: 'Tempo', + user_agent: 'User agent', + tab_details: 'Detalhes', + raw_data: 'Dados completos', +}; + +export default log_details; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/logs.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/logs.ts new file mode 100644 index 000000000..8233c7d4b --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/logs.ts @@ -0,0 +1,12 @@ +const logs = { + title: 'Logs', + subtitle: + 'Visualize os dados de log dos eventos de autenticação feitos por seu administrador e usuários', + event: 'Evento', + user: 'Usuário', + application: 'Aplicativo', + time: 'Tempo', + filter_by: 'Filtrar por', +}; + +export default logs; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/session-expired.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/session-expired.ts new file mode 100644 index 000000000..f4dde06b8 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/session-expired.ts @@ -0,0 +1,8 @@ +const session_expired = { + title: 'Sessão expirada', + subtitle: + 'Sua sessão pode ter expirado e você foi desconectado. Clique no botão abaixo para fazer login no Admin Console novamente.', + button: 'Entrar novamente', +}; + +export default session_expired; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/settings.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/settings.ts new file mode 100644 index 000000000..4ac2a87fa --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/settings.ts @@ -0,0 +1,25 @@ +const settings = { + title: 'Configurações', + description: 'Gerenciar as configurações globais', + settings: 'Configurações', + custom_domain: 'Domínio personalizado', + language: 'Idioma', + appearance: 'Aparência', + appearance_system: 'Sincronizar com o sistema', + appearance_light: 'Modo claro', + appearance_dark: 'Modo escuro', + saved: 'Salvou!', + change_password: 'Mudar senha', + change_password_description: + 'Você pode alterar a senha desta conta. Você usará o nome de usuário atual com a nova senha para entrar no Admin Console.', + change_modal_title: 'Modificar senha da conta', + change_modal_description: + 'Você usará o nome de usuário atual com a nova senha para entrar no Admin Console.', + new_password: 'Nova senha', + new_password_placeholder: 'Digite sua senha', + confirm_password: 'Confirme a senha', + confirm_password_placeholder: 'Confirme sua senha', + password_changed: 'Senha alterada!', +}; + +export default settings; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts new file mode 100644 index 000000000..9ab43d351 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts @@ -0,0 +1,178 @@ +const sign_in_exp = { + title: 'Experiência de login', + description: + 'Personalize a interface do usuário de login para corresponder à sua marca e visualize em tempo real', + tabs: { + branding: 'Marca', + sign_up_and_sign_in: 'Inscreva-se e faça login', + others: 'Outros', + }, + welcome: { + title: + 'Esta é a primeira vez que você define a experiência de login. Este guia irá ajudá-lo a passar por todas as configurações necessárias e começar rapidamente.', + get_started: 'Iniciar', + apply_remind: + 'Observe que a experiência de login será aplicada a todos os aplicativos nesta conta.', + got_it: 'Entendi', + }, + color: { + title: 'COR', + primary_color: 'Cor da marca', + dark_primary_color: 'Cor da marca (Escuro)', + dark_mode: 'Ativar modo escuro', + dark_mode_description: + 'Seu aplicativo terá um tema de modo escuro gerado automaticamente com base na cor da sua marca e no algoritmo Logto. Você é livre para personalizar.', + dark_mode_reset_tip: 'Recalcule a cor do modo escuro com base na cor da marca.', + reset: 'Recalcular', + }, + branding: { + title: 'ÁREA DE MARCA', + ui_style: 'Estilo', + styles: { + logo_slogan: 'Logo do aplicativo com slogan', + logo: 'Somente logotipo do aplicativo', + }, + logo_image_url: 'URL da imagem do logotipo do aplicativo', + logo_image_url_placeholder: 'https://your.cdn.domain/logo.png', + dark_logo_image_url: 'URL da imagem do logotipo do aplicativo (Escuro)', + dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png', + slogan: 'Slogan', + slogan_placeholder: 'Use sua criatividade', + }, + sign_up_and_sign_in: { + identifiers: 'Identificadores de inscrição', + identifiers_email: 'Endereço de e-mail', + identifiers_sms: 'Número de telefone', + identifiers_username: 'Nome de usuário', + identifiers_email_or_sms: 'Endereço de e-mail ou número de telefone', + identifiers_none: 'Não aplicável', + and: 'e', + or: 'ou', + sign_up: { + title: 'INSCREVER-SE', + sign_up_identifier: 'Identificador de inscrição', + identifier_description: + 'O identificador de inscrição é necessário para a criação da conta e deve ser incluído na tela de login.', + sign_up_authentication: 'Configuração de autenticação para inscrição', + authentication_description: + 'Todas as ações selecionadas serão obrigatórias para os usuários completarem o fluxo.', + set_a_password_option: 'Crie sua senha', + verify_at_sign_up_option: 'Visualize na inscrição', + social_only_creation_description: '(Isso se aplica apenas à criação de contas sociais)', + }, + sign_in: { + title: 'ENTRAR', + sign_in_identifier_and_auth: 'Configurações de identificador e autenticação para login', + description: + 'Os usuários podem entrar usando qualquer uma das opções disponíveis. Ajuste o layout arrastando e soltando as opções abaixo.', + add_sign_in_method: 'Adicionar método de login', + password_auth: 'Senha', + verification_code_auth: 'Código de verificação', + auth_swap_tip: 'Troque as opções abaixo para determinar qual aparece primeiro no fluxo.', + require_auth_factor: 'Você deve selecionar pelo menos um fator de autenticação.', + }, + social_sign_in: { + title: 'LOGIN SOCIAL', + social_sign_in: 'Login social', + description: + 'Dependendo do identificador obrigatório que você configurou, seu usuário pode ser solicitado a fornecer um identificador ao se inscrever via conector social.', + add_social_connector: 'Vincular Conector Social', + set_up_hint: { + not_in_list: 'Não está na lista?', + set_up_more: 'Configurar', + go_to: 'outros conectores sociais agora.', + }, + }, + tip: { + set_a_password: + 'Um conjunto exclusivo de uma senha para o seu nome de usuário é obrigatório.', + verify_at_sign_up: + 'No momento, suportamos apenas e-mail verificado. Sua base de usuários pode conter um grande número de endereços de e-mail de baixa qualidade se não houver validação.', + password_auth: + 'Isso é essencial, pois você habilitou a opção de definir uma senha durante o processo de inscrição.', + verification_code_auth: + 'Isso é essencial, pois você habilitou apenas a opção de fornecer o código de verificação ao se inscrever. Você pode desmarcar a caixa quando a configuração de senha for permitida no processo de inscrição.', + delete_sign_in_method: + 'Isso é essencial, pois você selecionou {{identifier}} como um identificador obrigatório.', + }, + }, + others: { + terms_of_use: { + title: 'TERMOS DE USO', + enable: 'Habilitar termos de uso', + description: 'Adicione os acordos legais para o uso do seu produto', + terms_of_use: 'Termos de uso', + terms_of_use_placeholder: 'https://your.terms.of.use/', + terms_of_use_tip: 'URL dos termos de uso', + }, + languages: { + title: 'IDIOMAS', + enable_auto_detect: 'Ativar detecção automática', + description: + 'Seu software detecta a configuração de localidade do usuário e muda para o idioma local. Você pode adicionar novos idiomas traduzindo a interface do usuário do inglês para outro idioma.', + manage_language: 'Gerenciar idioma', + default_language: 'Idioma padrão', + default_language_description_auto: + 'O idioma padrão será usado quando o idioma do usuário detectado não estiver coberto na biblioteca de idiomas atual.', + default_language_description_fixed: + 'Quando a detecção automática está desativada, o idioma padrão é o único idioma que seu software mostrará. Ative a detecção automática de extensão de idioma.', + }, + manage_language: { + title: 'Gerenciar idioma', + subtitle: + 'Localize a experiência do produto adicionando idiomas e traduções. Sua contribuição pode ser definida como o idioma padrão.', + add_language: 'Adicionar idioma', + logto_provided: 'Fornecido por Logto', + key: 'Chave', + logto_source_values: 'Valores Logto', + custom_values: 'Valores personalizados', + clear_all_tip: 'Limpar todos os valores', + unsaved_description: 'As alterações não serão salvas se você sair desta página sem salvar.', + deletion_tip: 'Excluir o idioma', + deletion_title: 'Deseja excluir o idioma adicionado?', + deletion_description: + 'Após a exclusão, seus usuários não poderão navegar naquele idioma novamente.', + default_language_deletion_title: 'O idioma padrão não pode ser excluído.', + default_language_deletion_description: + '{{language}} está definido como seu idioma padrão e não pode ser excluído. ', + got_it: 'Entendi', + }, + advanced_options: { + title: 'OPÇÕES AVANÇADAS', + enable_user_registration: 'Ativar registro de usuário', + enable_user_registration_description: + 'Habilitar ou desabilitar o registro do usuário. Depois de desativados, os usuários ainda podem ser adicionados no Admin Console, mas os usuários não podem mais estabelecer contas por meio da interface do usuário de login.', + }, + }, + setup_warning: { + no_connector: '', + no_connector_sms: + 'Nenhum conector SMS configurado ainda. Até terminar de configurar seu conector SMS, você não poderá fazer login. {{link}} em "Conectores"', + no_connector_email: + 'Nenhum conector e-mail configurado ainda. Até terminar de configurar seu conector SMS, você não poderá fazer login. {{link}} em "Conectores"', + no_connector_social: + 'Nenhum conector social configurado ainda. Até terminar de configurar seu conector SMS, você não poderá fazer login. {{link}} em "Conectores"', + no_added_social_connector: + 'Você configurou alguns conectores sociais agora. Certifique-se de adicionar alguns à sua experiência de login.', + setup_link: 'Configurar', + }, + save_alert: { + description: + 'Você está implementando novos procedimentos de entrada e inscrição. Todos os seus usuários podem ser afetados pela nova configuração. Tem certeza de se comprometer com a mudança?', + before: 'Antes', + after: 'Depois', + sign_up: 'Inscrever-se', + sign_in: 'Entrar', + social: 'Social', + }, + preview: { + title: 'Visualização de login', + dark: 'Escuro', + light: 'Claro', + native: 'Native', + desktop_web: 'Desktop Web', + mobile_web: 'Mobile Web', + }, +}; + +export default sign_in_exp; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/tab-sections.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/tab-sections.ts new file mode 100644 index 000000000..32347a9f6 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/tab-sections.ts @@ -0,0 +1,8 @@ +const tab_sections = { + overview: 'Visão geral', + resource_management: 'Recursos', + user_management: 'Usuários', + help_and_support: 'Ajuda e suporte', +}; + +export default tab_sections; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/tabs.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/tabs.ts new file mode 100644 index 000000000..ff48550a5 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/tabs.ts @@ -0,0 +1,15 @@ +const tabs = { + get_started: 'Primeiros passos', + dashboard: 'Painel', + applications: 'Aplicativos', + api_resources: 'Recursos da API', + sign_in_experience: 'Experiência de login', + connectors: 'Conectores', + users: 'Usuários', + audit_logs: 'Logs', + docs: 'Documentação', + contact_us: 'Contate-nos', + settings: 'Configurações', +}; + +export default tabs; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts new file mode 100644 index 000000000..83d7e781a --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/user-details.ts @@ -0,0 +1,43 @@ +const user_details = { + back_to_users: 'Voltar para gerenciamento de usuários', + created_title: 'Este usuário foi criado com sucesso', + created_guide: 'Você pode enviar as seguintes informações de login para o usuário', + created_username: 'Nome de usuário:', + created_password: 'Senha:', + menu_delete: 'Excluir', + delete_description: 'Essa ação não pode ser desfeita. Isso excluirá permanentemente o usuário.', + deleted: 'O usuário foi excluído com sucesso', + reset_password: { + reset_password: 'Redefinir senha', + title: 'Tem certeza de que deseja redefinir a senha?', + content: 'Essa ação não pode ser desfeita. Isso redefinirá as informações de login do usuário.', + congratulations: 'Este usuário foi redefinido', + new_password: 'Nova senha:', + }, + tab_logs: 'Logs', + settings: 'Configurações', + settings_description: + 'Cada usuário tem um perfil contendo todas as informações do usuário. Consiste em dados básicos, identidades sociais e dados personalizados.', + field_email: 'E-mail principal', + field_phone: 'Telefone principal', + field_username: 'Nome de usuário', + field_name: 'Nome', + field_avatar: 'URL da imagem do avatar', + field_avatar_placeholder: 'https://your.cdn.domain/avatar.png', + field_custom_data: 'Dados personalizados', + field_custom_data_tip: + 'Informações adicionais do usuário não listadas nas propriedades de usuário predefinidas, como cor e idioma preferidos do usuário.', + field_connectors: 'Conectores de login sociais', + custom_data_invalid: 'Os dados personalizados devem ser um objeto JSON válido', + connectors: { + connectors: 'Conectores', + user_id: 'ID do usuário', + remove: 'Remover', + not_connected: 'O usuário não está conectado a nenhum conector social', + deletion_confirmation: + 'Você está removendo a identidade existente. Você tem certeza que deseja fazer isso?', + }, + suspended: 'Suspenso', +}; + +export default user_details; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/users.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/users.ts new file mode 100644 index 000000000..fe7751d09 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/users.ts @@ -0,0 +1,15 @@ +const users = { + title: 'Gerenciamento de usuários', + subtitle: + 'Gerenciar identidades de usuários, visualização de logs de usuários, redefinições de senha e exclusão de usuários', + create: 'Adicionar usuário', + user_name: 'Usuário', + application_name: 'Aplicativo', + latest_sign_in: 'Último login', + create_form_username: 'Nome de usuário', + create_form_password: 'Senha', + create_form_name: 'Nome completo', + unnamed: 'Sem nome', +}; + +export default users; diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/welcome.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/welcome.ts new file mode 100644 index 000000000..6a96c1bbe --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/welcome.ts @@ -0,0 +1,8 @@ +const welcome = { + title: 'Bem-vindo ao Admin Console', + description: + 'O Admin Console é um aplicativo da web para gerenciar o Logto sem requisitos de codificação. Vamos primeiro criar uma conta. Com esta conta, você pode gerenciar o Logto sozinho ou em nome de sua empresa.', + create_account: 'Criar uma conta', +}; + +export default welcome; diff --git a/packages/phrases/src/locales/pt-br/translation/demo-app.ts b/packages/phrases/src/locales/pt-br/translation/demo-app.ts new file mode 100644 index 000000000..48cca8d14 --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/demo-app.ts @@ -0,0 +1,15 @@ +const demo_app = { + notification: + 'Use sua conta de administrador padrão ou crie uma nova conta para entrar no aplicativo de demonstração.', + title: 'Você se inscreveu com sucesso no aplicativo de demonstração!', + subtitle: 'Aqui estão suas informações de login:', + username: 'Nome de usuário: ', + user_id: 'ID do usuário: ', + sign_out: 'Sair do aplicativo de demonstração', + continue_explore: 'Ou continuar a navegação', + customize_sign_in_experience: 'Personalize a experiência de login', + enable_passwordless: 'Ativar sem senha', + add_social_connector: 'Adicionar conector login social', +}; + +export default demo_app; diff --git a/packages/phrases/src/locales/pt-br/translation/index.ts b/packages/phrases/src/locales/pt-br/translation/index.ts new file mode 100644 index 000000000..c8894ec9c --- /dev/null +++ b/packages/phrases/src/locales/pt-br/translation/index.ts @@ -0,0 +1,9 @@ +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; + +const translation = { + admin_console, + demo_app, +}; + +export default translation; diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 77f997f4e..919084e60 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -31,30 +31,34 @@ const errors = { provider_error: 'Erro interno OIDC: {{message}}.', }, user: { - username_exists_register: 'Já existe um utilizador com esse nome de utilizador.', - email_exists_register: 'Já existe um utilizador com esse endereço de email.', - phone_exists_register: 'Já existe um utilizador com esse numero do telefone.', + username_already_in_use: 'This username is already in use.', // UNTRANSLATED + email_already_in_use: 'This email is associated with an existing account.', // UNTRANSLATED + phone_already_in_use: 'This phone number is associated with an existing account.', // UNTRANSLATED invalid_email: 'Endereço de email inválido.', invalid_phone: 'Número de telefone inválido.', - email_not_exists: 'O endereço de email ainda não foi registada.', - phone_not_exists: 'O numero do telefone ainda não foi registada.', - identity_not_exists: 'A conta social ainda não foi registada.', - identity_exists: 'A conta social foi registada.', + email_not_exist: 'O endereço de email ainda não foi registada.', + phone_not_exist: 'O numero do telefone ainda não foi registada.', + identity_not_exist: 'A conta social ainda não foi registada.', + identity_already_in_use: 'A conta social foi registada.', invalid_role_names: '({{roleNames}}) não são válidos', cannot_delete_self: 'Não se pode remover a si mesmo.', - sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED - sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED + sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED + sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED - require_password: 'You need to set a password before signing-in.', // UNTRANSLATED - password_exists: 'Your password has been set.', // UNTRANSLATED - require_username: 'You need to set a username before signing-in.', // UNTRANSLATED - username_exists: 'This username is already in use.', // UNTRANSLATED - require_email: 'You need to add an email address before signing-in.', // UNTRANSLATED - email_exists: 'This email is associated with an existing account.', // UNTRANSLATED - require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED - sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED - require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED + password_required_in_profile: 'You need to set a password before signing-in.', // UNTRANSLATED + new_password_required_in_profile: 'You need to set a new password.', // UNTRANSLATED + password_exists_in_profile: 'Password already exists in your profile.', // UNTRANSLATED + username_required_in_profile: 'You need to set a username before signing-in.', // UNTRANSLATED + username_exists_in_profile: 'Username already exists in your profile.', // UNTRANSLATED + email_required_in_profile: 'You need to add an email address before signing-in.', // UNTRANSLATED + email_exists_in_profile: 'Your profile has already associated with an email address.', // UNTRANSLATED + phone_required_in_profile: 'You need to add a phone number before signing-in.', // UNTRANSLATED + phone_exists_in_profile: 'Your profile has already associated with a phone number.', // UNTRANSLATED + email_or_phone_required_in_profile: + 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED + user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, + missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.', @@ -77,6 +81,8 @@ const errors = { unauthorized: 'Faça login primeiro.', unsupported_prompt_name: 'Nome de prompt não suportado.', forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: 'Ocorreu um erro inesperado no conector.{{errorDescription}}', @@ -100,6 +106,15 @@ const errors = { more_than_one_sms: 'O número de conectores SMS é maior que 1.', more_than_one_email: 'O número de conectores de e-mail é maior que 1.', db_connector_type_mismatch: 'Há um conector no banco de dados que não corresponde ao tipo.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED + invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED + can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", // UNTRANSLATED }, passcode: { phone_email_empty: 'O campos telefone e email estão vazios.', diff --git a/packages/phrases/src/locales/pt-pt/index.ts b/packages/phrases/src/locales/pt-pt/index.ts index b6fa212b7..e3447c128 100644 --- a/packages/phrases/src/locales/pt-pt/index.ts +++ b/packages/phrases/src/locales/pt-pt/index.ts @@ -1,6 +1,6 @@ -import type { LocalPhrase } from '../../types'; -import errors from './errors'; -import translation from './translation'; +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; const ptPT: LocalPhrase = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resource-details.ts index d0b9849c9..171311dde 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: 'Voltar aos recursos API', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', // UNTRANSLATED token_expiration_time_in_seconds: 'Tempo de expiração do token (em segundos)', token_expiration_time_in_seconds_placeholder: 'Insira o tempo de expiração do token', delete_description: diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resources.ts index fc37bc6b2..656c0760b 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/api-resources.ts @@ -6,7 +6,7 @@ const api_resources = { api_name_placeholder: 'Introduza o nome da sua API', api_identifier: 'identificador da API', api_identifier_tip: - 'O identificador exclusivo para o recurso API. Deve ser um URI absoluto e não tem componente de fragmento (#). Igual ao resource parameter no OAuth 2.0.', + 'O identificador exclusivo para o recurso API. Deve ser um URI absoluto e não tem componente de fragmento (#). Igual ao resource parameter no OAuth 2.0.', api_resource_created: 'O recurso API {{name}} foi criado com sucesso', api_identifier_placeholder: 'https://your-api-identifier/', }; diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/application-details.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/application-details.ts index 648d1ff7b..7bbbf69de 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/application-details.ts @@ -1,22 +1,29 @@ const application_details = { back_to_applications: 'Voltar para aplicações', check_guide: 'Guia de verificação', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', // UNTRANSLATED advanced_settings: 'Configurações avançadas', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', // UNTRANSLATED application_name: 'Nome da aplicação', application_name_placeholder: 'Ex: Site Empresa', description: 'Descrição', description_placeholder: 'Insira a descrição da sua aplicação', authorization_endpoint: 'Endpoint de autorização', authorization_endpoint_tip: - 'O endpoint para realizar autenticação e autorização. É usado para autenticação OpenID Connect.', + 'O endpoint para realizar autenticação e autorização. É usado para autenticação OpenID Connect.', application_id: 'ID da aplicação', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', // UNTRANSLATED application_secret: 'Segredo da aplicação', redirect_uri: 'URI de redirecionamento', redirect_uris: 'URIs de redirecionamento', redirect_uri_placeholder: 'https://your.website.com/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - 'O URI redireciona após o login de um utilizador (com êxito ou não). Consulte OpenID Connect AuthRequest para obter mais informações.', + 'O URI redireciona após o login de um utilizador (com êxito ou não). Consulte OpenID Connect AuthRequest para obter mais informações.', post_sign_out_redirect_uri: 'URI de redirecionamento pós-logout', post_sign_out_redirect_uris: 'URIs de redirecionamento pós-logout', post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home', @@ -25,8 +32,7 @@ const application_details = { cors_allowed_origins: 'origens permitidas CORS', cors_allowed_origins_placeholder: 'https://your.website.com', cors_allowed_origins_tip: - 'Por padrão, todas as origens de redirecionamento serão permitidas. Recomenda-se restringir isto.', - add_another: 'Adicionar outro', + 'Por padrão, todas as origens de redirecionamento serão permitidas. Recomenda-se restringir isto. See the MDN doc for detailed info.', // UNTRANSLATED id_token_expiration: 'Expiração do token de ID', refresh_token_expiration: 'Expiração do token de atualização', token_endpoint: 'Endpoint Token', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/connector-details.ts index 2c081c494..9023ba2c1 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: 'Voltar para Conectores', check_readme: 'Verifique o README', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', // UNTRANSLATED save_error_empty_config: 'Por favor, insira a configuração', send: 'Enviar', send_error_invalid_format: 'Entrada inválida', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts index 2499f0edf..2a80ee5d8 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts @@ -30,6 +30,24 @@ const connectors = { }, guide: { subtitle: 'Um guia passo a passo para configurar o conector', + connector_setting: 'Connector setting', // UNTRANSLATED + name: 'Connector name', // UNTRANSLATED + name_tip: 'Connector button’s name will display as "Continue with {{Connector Name}}".', // UNTRANSLATED + logo: 'Connector logo URL', // UNTRANSLATED + logo_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_tip: 'The logo image will also display on the connector button.', // UNTRANSLATED + logo_dark: 'Connector logo URL (Dark mode)', // UNTRANSLATED + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_dark_tip: + 'This will be used when opening “Enable dark mode” in the setting of sign in experience.', // UNTRANSLATED + logo_dark_collapse: 'Collapse', // UNTRANSLATED + logo_dark_show: 'Show "Logo for dark mode"', // UNTRANSLATED + target: 'Connector identity target', // UNTRANSLATED + target_tip: 'A unique identifier for the connector.', // UNTRANSLATED + config: 'Enter your JSON here', // UNTRANSLATED + sync_profile: 'Sync profile information from the social provider', // UNTRANSLATED + sync_profile_only_at_register: 'Only sync at register', // UNTRANSLATED + sync_profile_each_sign_in: 'Always sync at each sign-in', // UNTRANSLATED }, platform: { universal: 'Universal', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/general.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/general.ts index 1bae83b75..becbc80e5 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: 'Guardar', save_changes: 'Guardar Alterações', saved: 'Guardado!', + discard: 'Discard', // UNTRANSLATED loading: 'Carregando...', redirecting: 'Redirecionando...', add: 'Adicionar', @@ -29,7 +30,7 @@ const general = { copying: 'Copiando', copied: 'Copiado', required: 'Necessário', - add_another: '+ Adicionar outro', + add_another: 'Adicionar outro', deletion_confirmation: 'Tem a certeza que deseja eliminar isso {{title}}?', settings_nav: 'Definições', unsaved_changes_warning: 'Fez algumas alterações. Tem a certeza que deseja sair desta página?', @@ -37,6 +38,9 @@ const general = { stay_on_page: 'Ficar na página', type_to_search: 'Type to search', // UNTRANSLATED got_it: 'Got it', // UNTRANSLATED + page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED + learn_more: 'Learn more', // UNTRANSLATED + tab_errors: '{{count, number}} errors', // UNTRANSLATED }; export default general; diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/index.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/index.ts index f63ff54a0..997ce0cf7 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: 'Consola de Administrador', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/settings.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/settings.ts index 7f86eb2a6..133ad9b49 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: 'Definições', description: 'Gerenciar as configurações globais', - tabs: { - general: 'Geral', - }, + settings: 'Definições', custom_domain: 'Domínio personalizado', language: 'Linguagem', appearance: 'Aparência', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts index 966c3ce13..dffe07509 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts @@ -68,6 +68,7 @@ const sign_in_exp = { password_auth: 'Password', // UNTRANSLATED verification_code_auth: 'Verification code', // UNTRANSLATED auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED + require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED }, social_sign_in: { title: 'SOCIAL SIGN-IN', // UNTRANSLATED @@ -134,8 +135,8 @@ const sign_in_exp = { '{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED got_it: 'Got It', // UNTRANSLATED }, - authentication: { - title: 'AUTENTICAÇÃO', + advanced_options: { + title: 'OPÇÕES AVANÇADAS', enable_user_registration: 'Enable user registration', // UNTRANSLATED enable_user_registration_description: 'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', // UNTRANSLATED @@ -144,13 +145,14 @@ const sign_in_exp = { setup_warning: { no_connector: '', no_connector_sms: - 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in.', // UNTRANSLATED + 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_email: - 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in.', // UNTRANSLATED + 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_social: - 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in.', // UNTRANSLATED + 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_added_social_connector: 'Configurou alguns conectores sociais agora. Certifique-se de adicionar alguns a experiência de login.', + setup_link: 'Set up', }, save_alert: { description: diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts index e5069bed9..44fe59f58 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/user-details.ts @@ -17,6 +17,9 @@ const user_details = { new_password: 'Nova password:', }, tab_logs: 'Registros do utilizador', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', // UNTRANSLATED field_email: 'Email', field_phone: 'Telefone', field_username: 'Nome de utilizador', diff --git a/packages/phrases/src/locales/pt-pt/translation/index.ts b/packages/phrases/src/locales/pt-pt/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/pt-pt/translation/index.ts +++ b/packages/phrases/src/locales/pt-pt/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 0b5049746..a213c2254 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -32,30 +32,34 @@ const errors = { provider_error: 'Dahili OIDC Hatası: {{message}}.', }, user: { - username_exists_register: 'Kullanıcı adı kaydedildi.', - email_exists_register: 'E-posta adresi kaydedildi.', - phone_exists_register: 'Telefon numarası kaydedildi.', + username_already_in_use: 'This username is already in use.', // UNTRANSLATED + email_already_in_use: 'This email is associated with an existing account.', // UNTRANSLATED + phone_already_in_use: 'This phone number is associated with an existing account.', // UNTRANSLATED invalid_email: 'Geçersiz e-posta adresi.', invalid_phone: 'Geçersiz telefon numarası.', - email_not_exists: 'E-posta adresi henüz kaydedilmedi.', - phone_not_exists: 'Telefon numarası henüz kaydedilmedi', - identity_not_exists: 'Sosyal platform hesabı henüz kaydedilmedi.', - identity_exists: 'Sosyal platform hesabı kaydedildi.', + email_not_exist: 'E-posta adresi henüz kaydedilmedi.', + phone_not_exist: 'Telefon numarası henüz kaydedilmedi', + identity_not_exist: 'Sosyal platform hesabı henüz kaydedilmedi.', + identity_already_in_use: 'Sosyal platform hesabı kaydedildi.', invalid_role_names: '({{roleNames}}) rol adları geçerli değil.', cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED - sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED - sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED + sign_up_method_not_enabled: 'This sign-up method is not enabled.', // UNTRANSLATED + sign_in_method_not_enabled: 'This sign-in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED - require_password: 'You need to set a password before signing-in.', // UNTRANSLATED - password_exists: 'Your password has been set.', // UNTRANSLATED - require_username: 'You need to set a username before signing-in.', // UNTRANSLATED - username_exists: 'This username is already in use.', // UNTRANSLATED - require_email: 'You need to add an email address before signing-in.', // UNTRANSLATED - email_exists: 'This email is associated with an existing account.', // UNTRANSLATED - require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED - sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED - require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED + password_required_in_profile: 'You need to set a password before signing-in.', // UNTRANSLATED + new_password_required_in_profile: 'You need to set a new password.', // UNTRANSLATED + password_exists_in_profile: 'Password already exists in your profile.', // UNTRANSLATED + username_required_in_profile: 'You need to set a username before signing-in.', // UNTRANSLATED + username_exists_in_profile: 'Username already exists in your profile.', // UNTRANSLATED + email_required_in_profile: 'You need to add an email address before signing-in.', // UNTRANSLATED + email_exists_in_profile: 'Your profile has already associated with an email address.', // UNTRANSLATED + phone_required_in_profile: 'You need to add a phone number before signing-in.', // UNTRANSLATED + phone_exists_in_profile: 'Your profile has already associated with a phone number.', // UNTRANSLATED + email_or_phone_required_in_profile: + 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED + user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, + missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.', @@ -77,6 +81,8 @@ const errors = { unauthorized: 'Lütfen önce oturum açın.', unsupported_prompt_name: 'Desteklenmeyen prompt adı.', forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: 'Bağlayıcıda beklenmeyen bir hata oldu.{{errorDescription}}', @@ -99,6 +105,15 @@ const errors = { more_than_one_sms: 'SMS bağlayıcılarının sayısı 1den fazla.', more_than_one_email: 'E-posta adresi bağlayıcılarının sayısı 1den fazla.', db_connector_type_mismatch: 'Dbde türle eşleşmeyen bir bağlayıcı var.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED + invalid_type_for_syncing_profile: 'You can only sync user profile with social connectors.', // UNTRANSLATED + can_not_modify_target: 'The connector target can not be modified.', // UNTRANSLATED + multiple_target_with_same_platform: + 'You can not have multiple social connectors that have same target and platform.', // UNTRANSLATED + cannot_change_metadata_for_non_standard_connector: + "This connector's `metadata` cannot be changed.", // UNTRANSLATED }, passcode: { phone_email_empty: 'Hem telefon hem de e-posta adresi yok.', diff --git a/packages/phrases/src/locales/tr-tr/index.ts b/packages/phrases/src/locales/tr-tr/index.ts index 55bd2f884..572e02c8b 100644 --- a/packages/phrases/src/locales/tr-tr/index.ts +++ b/packages/phrases/src/locales/tr-tr/index.ts @@ -1,6 +1,6 @@ -import type { LocalPhrase } from '../../types'; -import errors from './errors'; -import translation from './translation'; +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; const trTR: LocalPhrase = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resource-details.ts index c57d92fd0..e5fd5f62f 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: 'API Kaynaklarına geri dön', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', // UNTRANSLATED token_expiration_time_in_seconds: 'Token sona erme süresi (saniye)', token_expiration_time_in_seconds_placeholder: 'Token zaman aşım süresini giriniz', delete_description: diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resources.ts index 26e7a32bc..51063d05e 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/api-resources.ts @@ -6,7 +6,7 @@ const api_resources = { api_name_placeholder: 'API adını giriniz', api_identifier: 'API belirteci', api_identifier_tip: - 'Api kaynağına özgün belirteç. Mutlak URI olmalı ve parça bileşeni (#) içermemeli. OAuth 2.0deki kaynak parametresine eşittir.', + 'Api kaynağına özgün belirteç. Mutlak URI olmalı ve parça bileşeni (#) içermemeli. OAuth 2.0deki kaynak parametresine eşittir.', api_resource_created: '{{name}} API kaynağı başarıyla oluşturuldu', api_identifier_placeholder: 'https://your-api-identifier/', }; diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/application-details.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/application-details.ts index dc5466041..408fd0747 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/application-details.ts @@ -1,22 +1,29 @@ const application_details = { back_to_applications: 'Uygulamalara geri dön', check_guide: 'Kılavuza Göz At', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', // UNTRANSLATED advanced_settings: 'Gelişmiş Ayarlar', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', // UNTRANSLATED application_name: 'Uygulama Adı', application_name_placeholder: 'Uygulamam', description: 'Açıklama', description_placeholder: 'Uygulama açıklamasını giriniz', authorization_endpoint: 'Yetkilendirme bitiş noktası', authorization_endpoint_tip: - 'Kimlik doğrulama ve yetkilendirme gerçekleştirmek için bitiş noktası. OpenID Connect Authentication için kullanılır.', + 'Kimlik doğrulama ve yetkilendirme gerçekleştirmek için bitiş noktası. OpenID Connect Authentication için kullanılır.', application_id: 'Uygulama IDsi', + application_id_tip: + 'The unique application identifier normally generated by Logto. It also stands for “client_id” in OpenID Connect.', // UNTRANSLATED application_secret: 'Uygulama Sırrı', redirect_uri: 'Yönlendirme URIı', redirect_uris: 'Yönlendirme URIları', redirect_uri_placeholder: 'https://your.website.com/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - 'URI kullanıcı oturum açma işlemiden sonra yönlendirir (Başarılı olsa da olmasa da). Detaylı bilgi için OpenID Connect AuthRequesta bakınız.', + 'URI kullanıcı oturum açma işlemiden sonra yönlendirir (Başarılı olsa da olmasa da). Detaylı bilgi için OpenID Connect AuthRequesta bakınız.', post_sign_out_redirect_uri: 'Oturumdan Çıkış sonrası yönlendirme URIı', post_sign_out_redirect_uris: 'Oturumdan Çıkış sonrası yönlendirme URIları', post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home', @@ -25,8 +32,7 @@ const application_details = { cors_allowed_origins: 'CORS izinli originler', cors_allowed_origins_placeholder: 'https://your.website.com', cors_allowed_origins_tip: - 'Varsayılan olarak, Yönlendirme URIlerinin tüm originlerine izin verilir. Genellikle bu alan için herhangi bir işlem gerekmez.', - add_another: 'Bir tane daha ekle', + 'Varsayılan olarak, Yönlendirme URIlerinin tüm originlerine izin verilir. Genellikle bu alan için herhangi bir işlem gerekmez. See the MDN doc for detailed info.', // UNTRANSLATED id_token_expiration: 'ID Token sona erme süresi', refresh_token_expiration: 'Refresh Token sona erme süresi', token_endpoint: 'Token bitiş noktası', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/connector-details.ts index 1730766b1..23d0e2c5a 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: 'Connectorlara dön', check_readme: 'READMEye göz at', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', // UNTRANSLATED save_error_empty_config: 'Lütfen yapılandırmayı girin', send: 'Gönder', send_error_invalid_format: 'Geçersiz input', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts index 92c226579..35e120b22 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts @@ -31,6 +31,24 @@ const connectors = { }, guide: { subtitle: 'Connectorı yapılandırmak için adım adım kılavuz', + connector_setting: 'Connector setting', // UNTRANSLATED + name: 'Connector name', // UNTRANSLATED + name_tip: 'Connector button’s name will display as "Continue with {{Connector Name}}".', // UNTRANSLATED + logo: 'Connector logo URL', // UNTRANSLATED + logo_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_tip: 'The logo image will also display on the connector button.', // UNTRANSLATED + logo_dark: 'Connector logo URL (Dark mode)', // UNTRANSLATED + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', // UNTRANSLATED + logo_dark_tip: + 'This will be used when opening “Enable dark mode” in the setting of sign in experience.', // UNTRANSLATED + logo_dark_collapse: 'Collapse', // UNTRANSLATED + logo_dark_show: 'Show "Logo for dark mode"', // UNTRANSLATED + target: 'Connector identity target', // UNTRANSLATED + target_tip: 'A unique identifier for the connector.', // UNTRANSLATED + config: 'Enter your JSON here', // UNTRANSLATED + sync_profile: 'Sync profile information from the social provider', // UNTRANSLATED + sync_profile_only_at_register: 'Only sync at register', // UNTRANSLATED + sync_profile_each_sign_in: 'Always sync at each sign-in', // UNTRANSLATED }, platform: { universal: 'Evrensel', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/general.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/general.ts index 23b3492c6..7e715c678 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: 'Kaydet', save_changes: 'Değişiklikleri Kaydet', saved: 'Kaydedildi!', + discard: 'Discard', // UNTRANSLATED loading: 'Yükleniyor...', redirecting: 'Yönlendiriliyor...', add: 'Ekle', @@ -29,7 +30,7 @@ const general = { copying: 'Kopyalanıyor', copied: 'Kopyalandı', required: 'Gerekli', - add_another: '+ Bir tane daha ekle', + add_another: 'Bir tane daha ekle', deletion_confirmation: 'Bu dosyayı silmek istediğinize emin misiniz: {{title}}?', settings_nav: 'Ayarlar', unsaved_changes_warning: @@ -38,6 +39,9 @@ const general = { stay_on_page: 'Bu sayfada kal', type_to_search: 'Type to search', // UNTRANSLATED got_it: 'Got it', // UNTRANSLATED + page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED + learn_more: 'Learn more', // UNTRANSLATED + tab_errors: '{{count, number}} errors', // UNTRANSLATED }; export default general; diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/index.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/index.ts index aece609c0..d0e3612fd 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: 'Yönetici Paneli', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/settings.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/settings.ts index bc6ff96cb..ff3c5616d 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: 'Ayarlar', description: 'Genel ayarları yönet', - tabs: { - general: 'Genel', - }, + settings: 'Ayarlar', custom_domain: 'Özel alan', language: 'Dil', appearance: 'Görünüm', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts index 2a9e19036..90c0c027d 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts @@ -69,6 +69,7 @@ const sign_in_exp = { password_auth: 'Password', // UNTRANSLATED verification_code_auth: 'Verification code', // UNTRANSLATED auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', // UNTRANSLATED + require_auth_factor: 'You have to select at least one authentication factor.', // UNTRANSLATED }, social_sign_in: { title: 'SOCIAL SIGN-IN', // UNTRANSLATED @@ -135,8 +136,8 @@ const sign_in_exp = { '{{language}} is set as your default language and can’t be deleted. ', // UNTRANSLATED got_it: 'Got It', // UNTRANSLATED }, - authentication: { - title: 'AUTHENTICATION', + advanced_options: { + title: 'GELİŞMİŞ OPSİYONLAR', enable_user_registration: 'Enable user registration', // UNTRANSLATED enable_user_registration_description: 'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', // UNTRANSLATED @@ -145,13 +146,14 @@ const sign_in_exp = { setup_warning: { no_connector: '', no_connector_sms: - 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in.', // UNTRANSLATED + 'No SMS connector set-up yet. Until you finish configuring your SMS connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_email: - 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in.', // UNTRANSLATED + 'No email connector set-up yet. Until you finish configuring your email connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_connector_social: - 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in.', // UNTRANSLATED + 'No social connector set-up yet. Until you finish configuring your social connector, you won’t be able to sign in. {{link}} in "Connectors"', // UNTRANSLATED no_added_social_connector: 'Şimdi birkaç social connector kurdunuz. Oturum açma deneyiminize bazı şeyler eklediğinizden emin olun.', + setup_link: 'Set up', }, save_alert: { description: diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts index 86e2195c4..8b82d8fea 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/user-details.ts @@ -15,6 +15,9 @@ const user_details = { new_password: 'Yeni şifre:', }, tab_logs: 'Kullanıcı kayıtları', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', // UNTRANSLATED field_email: 'Öncelikli e-posta adresi', field_phone: 'Öncelikli telefon', field_username: 'Kullanıcı Adı', diff --git a/packages/phrases/src/locales/tr-tr/translation/index.ts b/packages/phrases/src/locales/tr-tr/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/tr-tr/translation/index.ts +++ b/packages/phrases/src/locales/tr-tr/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index fb0e069d4..a4b3f66f4 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -30,31 +30,33 @@ const errors = { provider_error: 'OIDC 内部错误: {{message}}', }, user: { - username_exists_register: '用户名已被注册', - email_exists_register: '邮箱地址已被注册', - phone_exists_register: '手机号码已被注册', - invalid_email: '邮箱地址不正确', - invalid_phone: '手机号码不正确', - username_not_exists: '用户名尚未注册', - email_not_exists: '邮箱地址尚未注册', - phone_not_exists: '手机号码尚未注册', - identity_not_exists: '该社交帐号尚未注册', - identity_exists: '该社交帐号已被注册', - invalid_role_names: '角色名称({{roleNames}})无效', - cannot_delete_self: '你无法删除自己', - sign_up_method_not_enabled: '注册方式尚未启用', - sign_in_method_not_enabled: '登录方式尚未启用', - same_password: '为确保你的账户安全,新密码不能与旧密码一致', - require_password: '请设置密码', - password_exists: '密码已设置过', - require_username: '请设置用户名', - username_exists: '该用户名已存在', - require_email: '请绑定邮箱地址', - email_exists: '该邮箱地址已被其它账户绑定', - require_sms: '请绑定手机号码', - sms_exists: '该手机号码已被其它账户绑定', - require_email_or_sms: '请绑定邮箱地址或手机号码', - suspended: '账号已被禁用', + username_already_in_use: '该用户名已被使用。', + email_already_in_use: '该邮箱地址已被使用。', + phone_already_in_use: '该手机号码已被使用。', + invalid_email: '邮箱地址不正确。', + invalid_phone: '手机号码不正确。', + email_not_exist: '邮箱地址尚未注册。', + phone_not_exist: '手机号码尚未注册。', + identity_not_exist: '该社交帐号尚未注册。', + identity_already_in_use: '该社交帐号已被注册。', + invalid_role_names: '角色名称({{roleNames}})无效。', + cannot_delete_self: '无法删除自己的账户。', + sign_up_method_not_enabled: '注册方式尚未启用。', + sign_in_method_not_enabled: '登录方式尚未启用。', + same_password: '为确保账户安全,新密码不能与旧密码一致。', + password_required_in_profile: '请设置登录密码。', + new_password_required_in_profile: '请设置新密码。', + password_exists_in_profile: '当前用户已设置密码,无需重复操作。', + username_required_in_profile: '请设置用户名。', + username_exists_in_profile: '当前用户已设置用户名,无需重复操作。', + email_required_in_profile: '请绑定邮箱地址', + email_exists_in_profile: '当前用户已绑定邮箱,无需重复操作。', + phone_required_in_profile: '请绑定手机号码。', + phone_exists_in_profile: '当前用户已绑定手机号,无需重复操作。', + email_or_phone_required_in_profile: '请绑定邮箱地址或手机号码。', + suspended: '账号已被禁用。', + user_not_exist: '未找到与 {{ identity }} 相关联的用户。', + missing_profile: '请于登录时提供必要的用户补充信息。', }, password: { unsupported_encryption_method: '不支持的加密方法 {{name}}', @@ -73,6 +75,7 @@ const errors = { unauthorized: '请先登录', unsupported_prompt_name: '不支持的 prompt name', forgot_password_not_enabled: '忘记密码功能没有开启。', + verification_failed: '验证失败,请重新验证。', }, connector: { general: '连接器发生未知错误{{errorDescription}}', @@ -95,6 +98,12 @@ const errors = { more_than_one_sms: '同时存在超过 1 个短信连接器', more_than_one_email: '同时存在超过 1 个邮件连接器', db_connector_type_mismatch: '数据库中存在一个类型不匹配的连接。', + not_found_with_connector_id: '找不到所给 connector id 对应的连接器', + multiple_instances_not_supported: '你选择的连接器不支持创建多实例。', + invalid_type_for_syncing_profile: '只有社交连接器可以开启用户档案同步。', + can_not_modify_target: '不可修改连接器 target。', + multiple_target_with_same_platform: '不能同时存在多个有相同 target 和平台类型的社交连接器。', + cannot_change_metadata_for_non_standard_connector: '不可配置该连接器的 metadata 参数。', }, passcode: { phone_email_empty: '手机号与邮箱地址均为空', diff --git a/packages/phrases/src/locales/zh-cn/index.ts b/packages/phrases/src/locales/zh-cn/index.ts index 3bc867641..c90c4c0be 100644 --- a/packages/phrases/src/locales/zh-cn/index.ts +++ b/packages/phrases/src/locales/zh-cn/index.ts @@ -1,6 +1,6 @@ -import type { LocalPhrase } from '../../types'; -import errors from './errors'; -import translation from './translation'; +import type { LocalPhrase } from '../../types.js'; +import errors from './errors.js'; +import translation from './translation/index.js'; const zhCN: LocalPhrase = Object.freeze({ translation, diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resource-details.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resource-details.ts index 064315262..ab17aad75 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resource-details.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resource-details.ts @@ -1,5 +1,8 @@ const api_resource_details = { back_to_api_resources: '返回 API 资源', + settings: '设置', + settings_description: + 'API resources, a.k.a. Resource Indicators, indicate the target services or resources to be requested, usually, a URI format variable representing the resource‘s identity.', // UNTRANSLATED token_expiration_time_in_seconds: 'Token 过期时间(秒)', token_expiration_time_in_seconds_placeholder: '请输入你的 token 过期时间', delete_description: diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resources.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resources.ts index dc90e49da..a4f331cb8 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resources.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/api-resources.ts @@ -7,7 +7,7 @@ const api_resources = { api_identifier: 'API Identifier', api_identifier_placeholder: 'https://your-api-identifier/', api_identifier_tip: - '对于 API 资源的唯一标识符。它必须是一个绝对 URI 并没有 fragment (#) 组件。等价于 OAuth 2.0 中的 resource parameter。', + '对于 API 资源的唯一标识符。它必须是一个绝对 URI 并没有 fragment (#) 组件。等价于 OAuth 2.0 中的 resource parameter。', api_resource_created: ' API 资源 {{name}} 已成功创建!', }; diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/application-details.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/application-details.ts index 93a297d74..a75e1a1b0 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/application-details.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/application-details.ts @@ -1,31 +1,38 @@ const application_details = { back_to_applications: '返回全部应用', check_guide: '查看指南', + settings: '设置', + settings_description: + 'Applications are used to identify your applications in Logto for OIDC, sign-in experience, audit logs, etc.', // UNTRANSLATED advanced_settings: '高级设置', + advanced_settings_description: + 'Advanced settings include OIDC related terms. You can check out the Token Endpoint for more information.', // UNTRANSLATED application_name: '应用名称', application_name_placeholder: '我的应用', description: '描述', description_placeholder: '请输入应用描述', authorization_endpoint: 'Authorization Endpoint', - authorization_endpoint_tip: '进行鉴权与授权的端点 endpoint。用于 OpenID Connect 中的鉴权流程。', + authorization_endpoint_tip: + '进行鉴权与授权的端点 endpoint。用于 OpenID Connect 中的 鉴权 流程。', application_id: 'App ID', + application_id_tip: + '应用的唯一标识,通常由 Logto 生成。等价于 OpenID Connect 中的 client_id。', application_secret: 'App Secret', redirect_uri: 'Redirect URI', redirect_uris: 'Redirect URIs', redirect_uri_placeholder: 'https://your.website.com/app', redirect_uri_placeholder_native: 'io.logto://callback', redirect_uri_tip: - '在用户登录完成(不论成功与否)后重定向的目标 URI。参见 OpenID Connect AuthRequest 以了解更多。', + '在用户登录完成(不论成功与否)后重定向的目标 URI。参见 OpenID Connect AuthRequest 以了解更多。', post_sign_out_redirect_uri: 'Post Sign-out Redirect URI', post_sign_out_redirect_uris: 'Post sign out redirect URIs', post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home', post_sign_out_redirect_uri_tip: - '在用户登出后重定向的目标 URI(可选)。在某些应用类型中可能无实质作用。', + '在用户退出登录后重定向的目标 URI(可选)。在某些应用类型中可能无实质作用。', cors_allowed_origins: 'CORS Allowed Origins', cors_allowed_origins_placeholder: 'https://your.website.com', cors_allowed_origins_tip: - '所有 Redirect URI 的 origin 将默认被允许。通常不需要对此字段进行操作。', - add_another: '新增', + '所有 Redirect URI 的 origin 将默认被允许。通常不需要对此字段进行操作。参见 MDN 文档以了解更多', id_token_expiration: 'ID Token 过期时间', refresh_token_expiration: 'Refresh Token 过期时间', token_endpoint: 'Token Endpoint', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/connector-details.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/connector-details.ts index 2721849d8..b15440541 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/connector-details.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/connector-details.ts @@ -1,6 +1,9 @@ const connector_details = { back_to_connectors: '返回连接器', check_readme: '查看 README', + settings: '设置', + settings_description: + 'Connectors play a critical role in Logto. With their help, Logto enables end-users to use passwordless registration or sign-in and the capabilities of signing in with social accounts.', // UNTRANSLATED save_error_empty_config: '请输入配置内容', send: '发送', send_error_invalid_format: '无效输入', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts index 206191760..107e93b28 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts @@ -29,6 +29,23 @@ const connectors = { }, guide: { subtitle: '参考以下步骤完成你的连接器设置', + connector_setting: '连接器设置', + name: '连接器名称', + name_tip: '连接器按钮名将会是「通过 {{连接器名称}} 登录」。', + logo: '连接器图标地址', + logo_placeholder: 'https://your.cdn.domain/logo.png', + logo_tip: '图标将会在连接器按钮中展示', + logo_dark: '连接器图标地址(深色模式)', + logo_dark_placeholder: 'https://your.cdn.domain/logo.png', + logo_dark_tip: '在登录体验设置中打开「启用深色模式」后生效', + logo_dark_collapse: '折叠', + logo_dark_show: '显示「深色模式图标」', + target: '连接器 target', + target_tip: '连接器标识符', + config: '请在此输入你的 JSON 配置', + sync_profile: '从社交服务商同步用户数据', + sync_profile_only_at_register: '仅在注册时同步', + sync_profile_each_sign_in: '每次登录都同步', }, platform: { universal: '通用', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/general.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/general.ts index 3d4d1fa55..44c7c4a3f 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/general.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/general.ts @@ -10,6 +10,7 @@ const general = { save: '保存', save_changes: '保存更改', saved: '保存成功!', + discard: 'Discard', // UNTRANSLATED loading: '读取中...', redirecting: '页面跳转中...', add: '添加', @@ -29,7 +30,7 @@ const general = { copying: '复制中', copied: '已复制', required: '必填', - add_another: '+ 新增', + add_another: '新增', deletion_confirmation: '你确定要删除这个 {{title}} 吗?', settings_nav: '设置', unsaved_changes_warning: '还有未保存的变更, 确定要离开吗?', @@ -37,6 +38,9 @@ const general = { stay_on_page: '留在此页', type_to_search: '输入搜索', got_it: 'Got it', // UNTRANSLATED + page_info: '{{min, number}}-{{max, number}} 共 {{total, number}} 条', // UNTRANSLATED + learn_more: 'Learn more', // UNTRANSLATED + tab_errors: '{{count, number}} errors', // UNTRANSLATED }; export default general; diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/index.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/index.ts index 9a95a6ddb..156b0534a 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/index.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/index.ts @@ -1,24 +1,24 @@ -import api_resource_details from './api-resource-details'; -import api_resources from './api-resources'; -import application_details from './application-details'; -import applications from './applications'; -import connector_details from './connector-details'; -import connectors from './connectors'; -import contact from './contact'; -import dashboard from './dashboard'; -import errors from './errors'; -import general from './general'; -import get_started from './get-started'; -import log_details from './log-details'; -import logs from './logs'; -import session_expired from './session-expired'; -import settings from './settings'; -import sign_in_exp from './sign-in-exp'; -import tab_sections from './tab-sections'; -import tabs from './tabs'; -import user_details from './user-details'; -import users from './users'; -import welcome from './welcome'; +import api_resource_details from './api-resource-details.js'; +import api_resources from './api-resources.js'; +import application_details from './application-details.js'; +import applications from './applications.js'; +import connector_details from './connector-details.js'; +import connectors from './connectors.js'; +import contact from './contact.js'; +import dashboard from './dashboard.js'; +import errors from './errors.js'; +import general from './general.js'; +import get_started from './get-started.js'; +import log_details from './log-details.js'; +import logs from './logs.js'; +import session_expired from './session-expired.js'; +import settings from './settings.js'; +import sign_in_exp from './sign-in-exp.js'; +import tab_sections from './tab-sections.js'; +import tabs from './tabs.js'; +import user_details from './user-details.js'; +import users from './users.js'; +import welcome from './welcome.js'; const admin_console = { title: '管理控制台', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/settings.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/settings.ts index fbb8e77f6..f01e1346c 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/settings.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/settings.ts @@ -1,9 +1,7 @@ const settings = { title: '设置', description: '管理全局设置', - tabs: { - general: '通用', - }, + settings: '设置', custom_domain: '自定义域名', language: '语言', appearance: '外观', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts index 4ff62ccca..673be2b74 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts @@ -63,6 +63,7 @@ const sign_in_exp = { password_auth: '密码', verification_code_auth: '验证码', auth_swap_tip: '交换以下选项的位置即可设定它们在用户登录流程中出现的先后。', + require_auth_factor: '请至少选择一种认证方式。', }, social_sign_in: { title: '社交登录', @@ -125,19 +126,23 @@ const sign_in_exp = { '你已设置{{language}}为你的默认语言,你无法删除默认语言。', got_it: '知道了', }, - authentication: { - title: '身份验证', - enable_user_registration: 'Enable user registration', // UNTRANSLATED + advanced_options: { + title: '高级选项', + enable_user_registration: '启用用户注册', enable_user_registration_description: - 'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', // UNTRANSLATED + '开启或关闭用户注册功能。一旦关闭,用户将无法通过登录界面自行注册账号,但管理员仍可通过管理控制台添加用户。', }, }, setup_warning: { no_connector: '', - no_connector_sms: '你尚未设置 SMS 短信连接器。在完成该配置前,你将无法登录。', - no_connector_email: '你尚未设置电子邮件连接器。在完成该配置前,你将无法登录。', - no_connector_social: '你尚未设置社交连接器。在完成该配置前,你将无法登录。', + no_connector_sms: + '你尚未设置 SMS 短信连接器。在完成该配置前,你将无法登录。{{link}}连接器。', + no_connector_email: + '你尚未设置电子邮件连接器。在完成该配置前,你将无法登录。{{link}}连接器。', + no_connector_social: + '你尚未设置社交连接器。在完成该配置前,你将无法登录。{{link}}连接器。', no_added_social_connector: '你已经成功设置了一些社交连接器。点按「+」添加一些到你的登录体验。', + setup_link: '立即设置', }, save_alert: { description: diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts index f6be0e960..c855c2501 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/user-details.ts @@ -15,6 +15,9 @@ const user_details = { new_password: '新密码:', }, tab_logs: '用户日志', + settings: 'Settings', // UNTRANSLATED + settings_description: + 'Each user has a profile containing all user information. It consists of basic data, social identities, and custom data.', // UNTRANSLATED field_email: '主要邮箱', field_phone: '主要手机号码', field_username: '用户名', diff --git a/packages/phrases/src/locales/zh-cn/translation/index.ts b/packages/phrases/src/locales/zh-cn/translation/index.ts index 4d123d04e..c8894ec9c 100644 --- a/packages/phrases/src/locales/zh-cn/translation/index.ts +++ b/packages/phrases/src/locales/zh-cn/translation/index.ts @@ -1,5 +1,5 @@ -import admin_console from './admin-console'; -import demo_app from './demo-app'; +import admin_console from './admin-console/index.js'; +import demo_app from './demo-app.js'; const translation = { admin_console, diff --git a/packages/phrases/src/types.ts b/packages/phrases/src/types.ts index 78c521c85..5c0a77aa1 100644 --- a/packages/phrases/src/types.ts +++ b/packages/phrases/src/types.ts @@ -1,3 +1,3 @@ -import type en from './locales/en'; +import type en from './locales/en/index.js'; export type LocalPhrase = typeof en; diff --git a/packages/phrases/tsconfig.json b/packages/phrases/tsconfig.json index ec160f030..41e099231 100644 --- a/packages/phrases/tsconfig.json +++ b/packages/phrases/tsconfig.json @@ -2,7 +2,9 @@ "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { "outDir": "lib", - "declaration": true + "declaration": true, + "moduleResolution": "nodenext", + "module": "esnext" }, "include": [ "src" diff --git a/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts b/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts index 2d4a21f19..644145283 100644 --- a/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts +++ b/packages/schemas/alterations/1.0.0_beta.10-1-logto-config.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.10-1663923211-machine-to-machine-app.ts b/packages/schemas/alterations/1.0.0_beta.10-1663923211-machine-to-machine-app.ts index bc1b8c694..a8bb4d683 100644 --- a/packages/schemas/alterations/1.0.0_beta.10-1663923211-machine-to-machine-app.ts +++ b/packages/schemas/alterations/1.0.0_beta.10-1663923211-machine-to-machine-app.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.10-1664265197-custom-phrases.ts b/packages/schemas/alterations/1.0.0_beta.10-1664265197-custom-phrases.ts index 846f8e0c4..5f496d401 100644 --- a/packages/schemas/alterations/1.0.0_beta.10-1664265197-custom-phrases.ts +++ b/packages/schemas/alterations/1.0.0_beta.10-1664265197-custom-phrases.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.11-1664347703-rename-language-key-to-tag.ts b/packages/schemas/alterations/1.0.0_beta.11-1664347703-rename-language-key-to-tag.ts index 5706b4bff..b17b1567e 100644 --- a/packages/schemas/alterations/1.0.0_beta.11-1664347703-rename-language-key-to-tag.ts +++ b/packages/schemas/alterations/1.0.0_beta.11-1664347703-rename-language-key-to-tag.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.11-1664356000-add-created-at-column-to-users.ts b/packages/schemas/alterations/1.0.0_beta.11-1664356000-add-created-at-column-to-users.ts index 5203c9a21..1e708b9d6 100644 --- a/packages/schemas/alterations/1.0.0_beta.11-1664356000-add-created-at-column-to-users.ts +++ b/packages/schemas/alterations/1.0.0_beta.11-1664356000-add-created-at-column-to-users.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.11-1664462389-correct-user-created-at-column-by-user-logs.ts b/packages/schemas/alterations/1.0.0_beta.11-1664462389-correct-user-created-at-column-by-user-logs.ts index ab619e0b8..9669dd100 100644 --- a/packages/schemas/alterations/1.0.0_beta.11-1664462389-correct-user-created-at-column-by-user-logs.ts +++ b/packages/schemas/alterations/1.0.0_beta.11-1664462389-correct-user-created-at-column-by-user-logs.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.14-1665300135-sign-in-sign-up.ts b/packages/schemas/alterations/1.0.0_beta.14-1665300135-sign-in-sign-up.ts index f04ab1b5c..08e172d8a 100644 --- a/packages/schemas/alterations/1.0.0_beta.14-1665300135-sign-in-sign-up.ts +++ b/packages/schemas/alterations/1.0.0_beta.14-1665300135-sign-in-sign-up.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import type { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; enum SignInMethodState { Primary = 'primary', diff --git a/packages/schemas/alterations/1.0.0_beta.14-1667283640-remove-forgot-password.ts b/packages/schemas/alterations/1.0.0_beta.14-1667283640-remove-forgot-password.ts index 3461db973..cfb0534de 100644 --- a/packages/schemas/alterations/1.0.0_beta.14-1667283640-remove-forgot-password.ts +++ b/packages/schemas/alterations/1.0.0_beta.14-1667283640-remove-forgot-password.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import type { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.14-1667292082-remove-sign-in-method.ts b/packages/schemas/alterations/1.0.0_beta.14-1667292082-remove-sign-in-method.ts index 245ef7042..a939e32d4 100644 --- a/packages/schemas/alterations/1.0.0_beta.14-1667292082-remove-sign-in-method.ts +++ b/packages/schemas/alterations/1.0.0_beta.14-1667292082-remove-sign-in-method.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import type { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.14-1667374974-user-suspend.ts b/packages/schemas/alterations/1.0.0_beta.14-1667374974-user-suspend.ts index 817f831fc..d48f7f6cf 100644 --- a/packages/schemas/alterations/1.0.0_beta.14-1667374974-user-suspend.ts +++ b/packages/schemas/alterations/1.0.0_beta.14-1667374974-user-suspend.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import type { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/1.0.0_beta.14-1667900481-add-passcode-type-continue.ts b/packages/schemas/alterations/1.0.0_beta.14-1667900481-add-passcode-type-continue.ts index 1aa07480c..fa5923886 100644 --- a/packages/schemas/alterations/1.0.0_beta.14-1667900481-add-passcode-type-continue.ts +++ b/packages/schemas/alterations/1.0.0_beta.14-1667900481-add-passcode-type-continue.ts @@ -1,6 +1,6 @@ import { sql } from 'slonik'; -import type { AlterationScript } from '../lib/types/alteration'; +import type { AlterationScript } from '../lib/types/alteration.js'; const alteration: AlterationScript = { up: async (pool) => { diff --git a/packages/schemas/alterations/next-1668666590-support-multiple-connector-instances.ts b/packages/schemas/alterations/next-1668666590-support-multiple-connector-instances.ts new file mode 100644 index 000000000..b2d9af7d0 --- /dev/null +++ b/packages/schemas/alterations/next-1668666590-support-multiple-connector-instances.ts @@ -0,0 +1,25 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + ALTER TABLE connectors ADD COLUMN sync_profile boolean NOT NULL DEFAULT false; + ALTER TABLE connectors ADD COLUMN connector_id varchar(128); + UPDATE connectors SET connector_id = id; + ALTER TABLE connectors ALTER COLUMN connector_id SET NOT NULL; + ALTER TABLE connectors ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb; + `); + }, + down: async (pool) => { + await pool.query(sql` + DELETE FROM connectors WHERE id <> connector_id; + ALTER TABLE connectors DROP COLUMN metadata; + ALTER TABLE connectors DROP COLUMN connector_id; + ALTER TABLE connectors DROP COLUMN sync_profile; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/alterations/next-1668666600-remove-connector-enabled.ts b/packages/schemas/alterations/next-1668666600-remove-connector-enabled.ts new file mode 100644 index 000000000..aeceedcb0 --- /dev/null +++ b/packages/schemas/alterations/next-1668666600-remove-connector-enabled.ts @@ -0,0 +1,20 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + DELETE FROM connectors WHERE enabled = false; + ALTER TABLE connectors DROP COLUMN enabled; + `); + }, + down: async (pool) => { + await pool.query(sql` + ALTER TABLE connectors ADD COLUMN enabled boolean NOT NULL DEFAULT false; + UPDATE connectors SET enabled = true; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/alterations/next-1669091623-roles-and-scopes.ts b/packages/schemas/alterations/next-1669091623-roles-and-scopes.ts new file mode 100644 index 000000000..ba5cebd78 --- /dev/null +++ b/packages/schemas/alterations/next-1669091623-roles-and-scopes.ts @@ -0,0 +1,43 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + -- scopes + create table scopes ( + id varchar(21) not null, + resource_id varchar(21) references resources (id) on update cascade on delete cascade, + name varchar(256) not null, + description text, + created_at timestamptz not null default(now()), + primary key (id) + ); + -- update table roles, add id and replace pkey + alter table roles add column id varchar(21); + update roles set id = name; + alter table roles alter column id set not null; + alter table roles drop constraint roles_pkey; + create unique index roles_pkey on roles using btree(id); + create unique index roles__name on roles (name); + -- roles_scopes + create table roles_scopes ( + role_id varchar(21) references roles (id) on update cascade on delete cascade, + scope_id varchar(21) references scopes (id) on update cascade on delete cascade, + constraint roles_permissison_pkey primary key (role_id, scope_id) + ); + `); + }, + down: async (pool) => { + await pool.query(sql` + drop table permissions; + alter index roles_pkey rename to roles_pkey_1; + create unique index roles_pkey on roles using btree(name) + drop index roles_pkey_1; + alter table roles drop column id; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/alterations/next-1669702299-sign-up.ts b/packages/schemas/alterations/next-1669702299-sign-up.ts new file mode 100644 index 000000000..c5c6b8f80 --- /dev/null +++ b/packages/schemas/alterations/next-1669702299-sign-up.ts @@ -0,0 +1,124 @@ +import { isSameArray } from '@silverhand/essentials'; +import type { DatabaseTransactionConnection } from 'slonik'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +enum DeprecatedSignUpIdentifier { + Email = 'email', + Sms = 'sms', + Username = 'username', + EmailOrSms = 'emailOrSms', + None = 'none', +} + +type DeprecatedSignUp = { + identifier: DeprecatedSignUpIdentifier; + password: boolean; + verify: boolean; +}; + +type DeprecatedSignInExperience = { + id: string; + signUp: DeprecatedSignUp; +}; + +enum SignInIdentifier { + Username = 'username', + Email = 'email', + Sms = 'sms', +} + +type SignUp = { + identifiers: SignInIdentifier[]; + password: boolean; + verify: boolean; +}; + +type SignInExperience = { + id: string; + signUp: SignUp; +}; + +const signUpIdentifierMapping: { + [key in DeprecatedSignUpIdentifier]: SignInIdentifier[]; +} = { + [DeprecatedSignUpIdentifier.Email]: [SignInIdentifier.Email], + [DeprecatedSignUpIdentifier.Sms]: [SignInIdentifier.Sms], + [DeprecatedSignUpIdentifier.Username]: [SignInIdentifier.Username], + [DeprecatedSignUpIdentifier.EmailOrSms]: [SignInIdentifier.Email, SignInIdentifier.Sms], + [DeprecatedSignUpIdentifier.None]: [], +}; + +const mapDeprecatedSignUpIdentifierToIdentifiers = (signUpIdentifier: DeprecatedSignUpIdentifier) => + signUpIdentifierMapping[signUpIdentifier]; + +const alterSignUp = async ( + signInExperience: DeprecatedSignInExperience, + pool: DatabaseTransactionConnection +) => { + const { + id, + signUp: { identifier, ...signUpSettings }, + } = signInExperience; + + const signUpIdentifiers = mapDeprecatedSignUpIdentifierToIdentifiers(identifier); + + const signUp: SignUp = { + identifiers: signUpIdentifiers, + ...signUpSettings, + }; + + await pool.query( + sql`update sign_in_experiences set sign_up = ${JSON.stringify(signUp)} where id = ${id}` + ); +}; + +const mapIdentifiersToDeprecatedSignUpIdentifier = ( + identifiers: SignInIdentifier[] +): DeprecatedSignUpIdentifier => { + for (const [key, mappedIdentifiers] of Object.entries(signUpIdentifierMapping)) { + if (isSameArray(identifiers, mappedIdentifiers)) { + // eslint-disable-next-line no-restricted-syntax + return key as DeprecatedSignUpIdentifier; + } + } + + throw new Error('Invalid identifiers in the sign up settings.'); +}; + +const rollbackSignUp = async ( + signInExperience: SignInExperience, + pool: DatabaseTransactionConnection +) => { + const { + id, + signUp: { identifiers, ...signUpSettings }, + } = signInExperience; + + const signUpIdentifier = mapIdentifiersToDeprecatedSignUpIdentifier(identifiers); + + const signUp: DeprecatedSignUp = { + identifier: signUpIdentifier, + ...signUpSettings, + }; + + await pool.query( + sql`update sign_in_experiences set sign_up = ${JSON.stringify(signUp)} where id = ${id}` + ); +}; + +const alteration: AlterationScript = { + up: async (pool) => { + const rows = await pool.many( + sql`select * from sign_in_experiences` + ); + await Promise.all(rows.map(async (row) => alterSignUp(row, pool))); + }, + down: async (pool) => { + const rows = await pool.many(sql`select * from sign_in_experiences`); + await Promise.all(rows.map(async (row) => rollbackSignUp(row, pool))); + }, +}; + +export default alteration; diff --git a/packages/schemas/generate.sh b/packages/schemas/generate.sh new file mode 100755 index 000000000..f20e26984 --- /dev/null +++ b/packages/schemas/generate.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +rm -rf lib/ +pnpm exec tsc -p tsconfig.build.gen.json +rm -rf src/db-entries +node lib/index.js +pnpm exec eslint src/db-entries/** --fix diff --git a/packages/schemas/jest.config.js b/packages/schemas/jest.config.js new file mode 100644 index 000000000..3fd28f230 --- /dev/null +++ b/packages/schemas/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('jest').Config} */ +const config = { + coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'], + coverageReporters: ['text-summary', 'lcov'], + roots: ['./lib'], + moduleNameMapper: { + '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + }, +}; + +export default config; diff --git a/packages/schemas/jest.config.ts b/packages/schemas/jest.config.ts deleted file mode 100644 index 0a9aa1b2e..000000000 --- a/packages/schemas/jest.config.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@silverhand/jest-config'; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index d883ba512..457f716bb 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -4,6 +4,7 @@ "main": "lib/index.js", "author": "Silverhand Inc. ", "license": "MPL-2.0", + "type": "module", "files": [ "lib", "alterations", @@ -16,15 +17,17 @@ "scripts": { "precommit": "lint-staged", "version": "./update-next.sh && git add alterations/", - "generate": "rm -rf src/db-entries && ts-node src/gen/index.ts && eslint \"src/db-entries/**\" --fix", + "generate": "./generate.sh", "build:alterations": "rm -rf alterations-js && tsc -p tsconfig.build.alterations.json", "build": "pnpm generate && rm -rf lib/ && tsc -p tsconfig.build.json && pnpm build:alterations", + "build:test": "pnpm generate && rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build", - "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" }, "engines": { "node": "^16.13.0 || ^18.12.0" @@ -32,13 +35,12 @@ "devDependencies": { "@silverhand/eslint-config": "1.3.0", "@silverhand/essentials": "^1.3.0", - "@silverhand/jest-config": "1.2.2", "@silverhand/ts-config": "1.2.1", "@types/jest": "^29.1.2", "@types/lodash.uniq": "^4.5.6", "@types/node": "^16.0.0", "@types/pluralize": "^0.0.29", - "camelcase": "^6.2.0", + "camelcase": "^7.0.0", "eslint": "^8.21.0", "jest": "^29.1.2", "lint-staged": "^13.0.0", @@ -46,8 +48,7 @@ "pluralize": "^8.0.0", "prettier": "^2.7.1", "slonik": "^30.0.0", - "ts-node": "^10.9.1", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "eslintConfig": { "extends": "@silverhand", @@ -67,11 +68,11 @@ }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { - "@logto/connector-kit": "1.0.0-beta.27", - "@logto/core-kit": "1.0.0-beta.20", - "@logto/language-kit": "1.0.0-beta.20", - "@logto/phrases": "workspace:^", - "@logto/phrases-ui": "workspace:^", + "@logto/connector-kit": "1.0.0-beta.28", + "@logto/core-kit": "1.0.0-beta.28", + "@logto/language-kit": "1.0.0-beta.28", + "@logto/phrases": "workspace:*", + "@logto/phrases-ui": "workspace:*", "zod": "^3.19.1" } } diff --git a/packages/schemas/src/api/index.ts b/packages/schemas/src/api/index.ts index 93ae819ea..022699d14 100644 --- a/packages/schemas/src/api/index.ts +++ b/packages/schemas/src/api/index.ts @@ -1 +1 @@ -export * from './error'; +export * from './error.js'; diff --git a/packages/schemas/src/foundations/index.ts b/packages/schemas/src/foundations/index.ts index 2cd17d221..33b1ce241 100644 --- a/packages/schemas/src/foundations/index.ts +++ b/packages/schemas/src/foundations/index.ts @@ -1,2 +1,2 @@ -export * from './schemas'; -export * from './jsonb-types'; +export * from './schemas.js'; +export * from './jsonb-types.js'; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 9cfb41065..224e32578 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -2,6 +2,11 @@ import { hexColorRegEx } from '@logto/core-kit'; import { languageTagGuard } from '@logto/language-kit'; import { z } from 'zod'; +export { + configurableConnectorMetadataGuard, + type ConfigurableConnectorMetadata, +} from '@logto/connector-kit'; + /** * Commonly Used */ @@ -128,28 +133,20 @@ export const languageInfoGuard = z.object({ export type LanguageInfo = z.infer; -export enum SignUpIdentifier { +export enum SignInIdentifier { + Username = 'username', Email = 'email', Sms = 'sms', - Username = 'username', - EmailOrSms = 'emailOrSms', - None = 'none', } export const signUpGuard = z.object({ - identifier: z.nativeEnum(SignUpIdentifier), + identifiers: z.nativeEnum(SignInIdentifier).array(), password: z.boolean(), verify: z.boolean(), }); export type SignUp = z.infer; -export enum SignInIdentifier { - Email = 'email', - Sms = 'sms', - Username = 'username', -} - export const signInGuard = z.object({ methods: z .object({ diff --git a/packages/schemas/src/gen/index.ts b/packages/schemas/src/gen/index.ts index 850c244f2..aabe6c768 100644 --- a/packages/schemas/src/gen/index.ts +++ b/packages/schemas/src/gen/index.ts @@ -10,15 +10,15 @@ import camelcase from 'camelcase'; import uniq from 'lodash.uniq'; import pluralize from 'pluralize'; -import { generateSchema } from './schema'; -import type { FileData, Table, Field, Type, GeneratedType, TableWithType } from './types'; +import { generateSchema } from './schema.js'; +import type { FileData, Table, Field, Type, GeneratedType, TableWithType } from './types.js'; import { findFirstParentheses, normalizeWhitespaces, parseType, removeUnrecognizedComments, splitTableFieldDefinitions, -} from './utils'; +} from './utils.js'; const directory = 'tables'; const constrainedKeywords = [ @@ -95,7 +95,7 @@ const generate = async () => { const generatedDirectory = 'src/db-entries'; const generatedTypesFilename = 'custom-types'; - const tsTypesFilename = '../foundations'; + const tsTypesFilename = '../foundations/index'; const header = '// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\n'; await fs.rm(generatedDirectory, { recursive: true, force: true }); @@ -167,7 +167,7 @@ const generate = async () => { uniq(tsTypes) .map((value) => ` ${value}`) .join(',\n'), - `} from './${tsTypesFilename}';`, + `} from'./${tsTypesFilename}.js';`, ].join('\n') + '\n\n' ); @@ -178,7 +178,7 @@ const generate = async () => { uniq(customTypes) .map((value) => ` ${value}`) .join(',\n'), - `} from './${generatedTypesFilename}';`, + `} from'./${generatedTypesFilename}.js';`, ].join('\n') + '\n\n' ); @@ -196,8 +196,8 @@ const generate = async () => { await fs.writeFile( path.join(generatedDirectory, 'index.ts'), header + - conditionalString(allTypes.length > 0 && `export * from './${generatedTypesFilename}';\n`) + - generated.map(([file]) => `export * from './${getOutputFileName(file)}';`).join('\n') + + conditionalString(allTypes.length > 0 && `export * from'./${generatedTypesFilename}.js';\n`) + + generated.map(([file]) => `export * from'./${getOutputFileName(file)}.js';`).join('\n') + '\n' ); }; diff --git a/packages/schemas/src/gen/schema.ts b/packages/schemas/src/gen/schema.ts index 09ed9256e..3f3558d5f 100644 --- a/packages/schemas/src/gen/schema.ts +++ b/packages/schemas/src/gen/schema.ts @@ -4,7 +4,7 @@ import { conditionalString } from '@silverhand/essentials'; import camelcase from 'camelcase'; import pluralize from 'pluralize'; -import type { TableWithType } from './types'; +import type { TableWithType } from './types.js'; export const generateSchema = ({ name, fields }: TableWithType) => { const modelName = pluralize(camelcase(name, { pascalCase: true }), 1); diff --git a/packages/schemas/src/gen/utils.test.ts b/packages/schemas/src/gen/utils.test.ts index 655833389..679dc630e 100644 --- a/packages/schemas/src/gen/utils.test.ts +++ b/packages/schemas/src/gen/utils.test.ts @@ -1,4 +1,4 @@ -import { parseType, getType, splitTableFieldDefinitions } from './utils'; +import { parseType, getType, splitTableFieldDefinitions } from './utils.js'; describe('splitTableFieldDefinitions', () => { it('splitTableFieldDefinitions should split at each comma that is not in the parentheses', () => { diff --git a/packages/schemas/src/gen/utils.ts b/packages/schemas/src/gen/utils.ts index a7fa600be..213684876 100644 --- a/packages/schemas/src/gen/utils.ts +++ b/packages/schemas/src/gen/utils.ts @@ -1,7 +1,7 @@ import type { Optional } from '@silverhand/essentials'; import { conditional, assert } from '@silverhand/essentials'; -import type { Field } from './types'; +import type { Field } from './types.js'; export const normalizeWhitespaces = (string: string): string => string.replace(/\s+/g, ' ').trim(); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 19f0dd248..23a82911b 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -1,5 +1,5 @@ -export * from './foundations'; -export * from './db-entries'; -export * from './types'; -export * from './api'; -export * as seeds from './seeds'; +export * from './foundations/index.js'; +export * from './db-entries/index.js'; +export * from './types/index.js'; +export * from './api/index.js'; +export * as seeds from './seeds/index.js'; diff --git a/packages/schemas/src/seeds/application.ts b/packages/schemas/src/seeds/application.ts index db3301c2c..c3214b82b 100644 --- a/packages/schemas/src/seeds/application.ts +++ b/packages/schemas/src/seeds/application.ts @@ -1,5 +1,5 @@ -import type { CreateApplication } from '../db-entries'; -import { ApplicationType } from '../db-entries'; +import type { CreateApplication } from '../db-entries/index.js'; +import { ApplicationType } from '../db-entries/index.js'; /** * The fixed application ID for Admin Console. diff --git a/packages/schemas/src/seeds/index.ts b/packages/schemas/src/seeds/index.ts index 21714fa99..8e9523955 100644 --- a/packages/schemas/src/seeds/index.ts +++ b/packages/schemas/src/seeds/index.ts @@ -1,5 +1,5 @@ -export * from './application'; -export * from './resource'; -export * from './setting'; -export * from './sign-in-experience'; -export * from './roles'; +export * from './application.js'; +export * from './resource.js'; +export * from './setting.js'; +export * from './sign-in-experience.js'; +export * from './roles.js'; diff --git a/packages/schemas/src/seeds/resource.ts b/packages/schemas/src/seeds/resource.ts index ddaf88735..b8e13eaad 100644 --- a/packages/schemas/src/seeds/resource.ts +++ b/packages/schemas/src/seeds/resource.ts @@ -1,4 +1,4 @@ -import type { CreateResource } from '../db-entries'; +import type { CreateResource } from '../db-entries/index.js'; export const managementResource: Readonly = Object.freeze({ id: 'management-api', diff --git a/packages/schemas/src/seeds/roles.ts b/packages/schemas/src/seeds/roles.ts index eeb18e807..fc79930cd 100644 --- a/packages/schemas/src/seeds/roles.ts +++ b/packages/schemas/src/seeds/roles.ts @@ -1,10 +1,11 @@ -import type { CreateRole } from '../db-entries'; -import { UserRole } from '../types'; +import type { CreateRole } from '../db-entries/index.js'; +import { UserRole } from '../types/index.js'; /** * Default Admin Role for Admin Console. */ export const defaultRole: Readonly = { + id: 'ac-admin-id', name: UserRole.Admin, description: 'Admin role for Logto.', }; diff --git a/packages/schemas/src/seeds/setting.ts b/packages/schemas/src/seeds/setting.ts index 04dbe1069..10a321e86 100644 --- a/packages/schemas/src/seeds/setting.ts +++ b/packages/schemas/src/seeds/setting.ts @@ -1,5 +1,5 @@ -import type { CreateSetting } from '../db-entries'; -import { AppearanceMode } from '../foundations'; +import type { CreateSetting } from '../db-entries/index.js'; +import { AppearanceMode } from '../foundations/index.js'; export const defaultSettingId = 'default'; diff --git a/packages/schemas/src/seeds/sign-in-experience.ts b/packages/schemas/src/seeds/sign-in-experience.ts index c32dfcec4..b5123ff64 100644 --- a/packages/schemas/src/seeds/sign-in-experience.ts +++ b/packages/schemas/src/seeds/sign-in-experience.ts @@ -1,8 +1,8 @@ import { generateDarkColor } from '@logto/core-kit'; -import type { CreateSignInExperience } from '../db-entries'; -import { SignInMode } from '../db-entries'; -import { BrandingStyle, SignInIdentifier, SignUpIdentifier } from '../foundations'; +import type { CreateSignInExperience } from '../db-entries/index.js'; +import { SignInMode } from '../db-entries/index.js'; +import { BrandingStyle, SignInIdentifier } from '../foundations/index.js'; const defaultPrimaryColor = '#6139F6'; @@ -26,7 +26,7 @@ export const defaultSignInExperience: Readonly = { enabled: false, }, signUp: { - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], password: true, verify: false, }, diff --git a/packages/schemas/src/types/connector.ts b/packages/schemas/src/types/connector.ts index 83e75cdab..78bf9ce5a 100644 --- a/packages/schemas/src/types/connector.ts +++ b/packages/schemas/src/types/connector.ts @@ -1,6 +1,6 @@ import type { BaseConnector, ConnectorMetadata, ConnectorType } from '@logto/connector-kit'; -import type { Connector } from '../db-entries'; +import type { Connector } from '../db-entries/index.js'; export type { ConnectorMetadata } from '@logto/connector-kit'; export { ConnectorType, ConnectorPlatform } from '@logto/connector-kit'; @@ -8,3 +8,9 @@ export { ConnectorType, ConnectorPlatform } from '@logto/connector-kit'; export type ConnectorResponse = Connector & Omit, 'configGuard' | 'metadata'> & ConnectorMetadata; + +export type ConnectorFactoryResponse = Omit< + BaseConnector, + 'configGuard' | 'metadata' +> & + ConnectorMetadata; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 9bb6fd003..92e1adb6e 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -1,5 +1,6 @@ -export * from './connector'; -export * from './log'; -export * from './oidc-config'; -export * from './user'; -export * from './logto-config'; +export * from './connector.js'; +export * from './log.js'; +export * from './oidc-config.js'; +export * from './user.js'; +export * from './logto-config.js'; +export * from './interactions.js'; diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts new file mode 100644 index 000000000..082f62c89 --- /dev/null +++ b/packages/schemas/src/types/interactions.ts @@ -0,0 +1,96 @@ +import { emailRegEx, phoneRegEx, usernameRegEx, passwordRegEx } from '@logto/core-kit'; +import { z } from 'zod'; + +/** + * Detailed Identifier Methods guard + */ + +export const usernamePasswordPayloadGuard = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); +export type UsernamePasswordPayload = z.infer; + +export const emailPasswordPayloadGuard = z.object({ + email: z.string().min(1), + password: z.string().min(1), +}); +export type EmailPasswordPayload = z.infer; + +export const phonePasswordPayloadGuard = z.object({ + phone: z.string().min(1), + password: z.string().min(1), +}); +export type PhonePasswordPayload = z.infer; + +export const emailPasscodePayloadGuard = z.object({ + email: z.string().regex(emailRegEx), + passcode: z.string().min(1), +}); +export type EmailPasscodePayload = z.infer; + +export const phonePasscodePayloadGuard = z.object({ + phone: z.string().regex(phoneRegEx), + passcode: z.string().min(1), +}); +export type PhonePasscodePayload = z.infer; + +export const socialConnectorPayloadGuard = z.object({ + connectorId: z.string(), + connectorData: z.unknown(), +}); +export type SocialConnectorPayload = z.infer; + +export const socialIdentityPayloadGuard = z.object({ + connectorId: z.string(), + identityType: z.union([z.literal('phone'), z.literal('email')]), +}); +export type SocialIdentityPayload = z.infer; + +/** + * Interaction Payload Guard + */ +export enum Event { + SignIn = 'SignIn', + Register = 'Register', + ForgotPassword = 'ForgotPassword', +} + +export const eventGuard = z.nativeEnum(Event); + +export const identifierPayloadGuard = z.union([ + usernamePasswordPayloadGuard, + emailPasswordPayloadGuard, + phonePasswordPayloadGuard, + emailPasscodePayloadGuard, + phonePasscodePayloadGuard, + socialConnectorPayloadGuard, + socialIdentityPayloadGuard, +]); + +export type IdentifierPayload = + | UsernamePasswordPayload + | EmailPasswordPayload + | PhonePasswordPayload + | EmailPasscodePayload + | PhonePasscodePayload + | SocialConnectorPayload + | SocialIdentityPayload; + +export const profileGuard = z.object({ + username: z.string().regex(usernameRegEx).optional(), + email: z.string().regex(emailRegEx).optional(), + phone: z.string().regex(phoneRegEx).optional(), + connectorId: z.string().optional(), + password: z.string().regex(passwordRegEx).optional(), +}); + +export type Profile = z.infer; + +export enum MissingProfile { + username = 'username', + email = 'email', + phone = 'phone', + password = 'password', + emailOrPhone = 'emailOrPhone', +} diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index 9bf65948b..b359a2f90 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import type { Log } from '../db-entries'; +import type { Log } from '../db-entries/index.js'; export enum LogResult { Success = 'Success', diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 1830b9d31..9e05c76d2 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -1,4 +1,4 @@ -import type { CreateUser } from '../db-entries'; +import type { CreateUser } from '../db-entries/index.js'; export const userInfoSelectFields = Object.freeze([ 'id', diff --git a/packages/schemas/tables/connectors.sql b/packages/schemas/tables/connectors.sql index 7ddda1884..d8d5396f4 100644 --- a/packages/schemas/tables/connectors.sql +++ b/packages/schemas/tables/connectors.sql @@ -1,7 +1,9 @@ create table connectors ( id varchar(128) not null, - enabled boolean not null default FALSE, + sync_profile boolean not null default FALSE, + connector_id varchar(128) not null, config jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb, + metadata jsonb /* @use ConfigurableConnectorMetadata */ not null default '{}'::jsonb, created_at timestamptz not null default(now()), primary key (id) ); diff --git a/packages/schemas/tables/roles.sql b/packages/schemas/tables/roles.sql index 520ddcc5b..f08ba85d1 100644 --- a/packages/schemas/tables/roles.sql +++ b/packages/schemas/tables/roles.sql @@ -1,5 +1,11 @@ create table roles ( - name varchar(128) not null, - description varchar(128) not null, - primary key (name) + id varchar(21) not null, + name varchar(128) not null, + description varchar(128) not null, + primary key (id) +); + +create unique index roles__name +on roles ( + name ); diff --git a/packages/schemas/tables/scopes.sql b/packages/schemas/tables/scopes.sql new file mode 100644 index 000000000..f4f1f406e --- /dev/null +++ b/packages/schemas/tables/scopes.sql @@ -0,0 +1,8 @@ +create table scopes ( + id varchar(21) not null, + resource_id varchar(21) references resources (id) on update cascade on delete cascade, + name varchar(256) not null, + description text, + created_at timestamptz not null default(now()), + primary key (id) +); diff --git a/packages/schemas/tables/scopesroles.sql b/packages/schemas/tables/scopesroles.sql new file mode 100644 index 000000000..b659da2f4 --- /dev/null +++ b/packages/schemas/tables/scopesroles.sql @@ -0,0 +1,5 @@ +create table roles_scopes ( + role_id varchar(21) references roles (id) on update cascade on delete cascade, + scope_id varchar(21) references scopes (id) on update cascade on delete cascade, + primary key (role_id, scope_id) +); diff --git a/packages/schemas/tsconfig.build.gen.json b/packages/schemas/tsconfig.build.gen.json new file mode 100644 index 000000000..eeed8fa29 --- /dev/null +++ b/packages/schemas/tsconfig.build.gen.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "include": ["src/gen"] +} diff --git a/packages/schemas/tsconfig.json b/packages/schemas/tsconfig.json index 6dac33b10..05e3acb09 100644 --- a/packages/schemas/tsconfig.json +++ b/packages/schemas/tsconfig.json @@ -2,11 +2,12 @@ "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { "outDir": "lib", - "declaration": true + "declaration": true, + "moduleResolution": "nodenext", + "module": "esnext" }, "include": [ "src", - "alterations", - "jest.config.ts" + "alterations" ] } diff --git a/packages/schemas/tsconfig.test.json b/packages/schemas/tsconfig.test.json index 1c66acf6d..55de18c33 100644 --- a/packages/schemas/tsconfig.test.json +++ b/packages/schemas/tsconfig.test.json @@ -1,3 +1,8 @@ { - "extends": "./tsconfig" + "extends": "./tsconfig", + "compilerOptions": { + "isolatedModules": false, + "allowJs": true + }, + "include": ["src"] } diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js new file mode 100644 index 000000000..3fd28f230 --- /dev/null +++ b/packages/shared/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('jest').Config} */ +const config = { + coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'], + coverageReporters: ['text-summary', 'lcov'], + roots: ['./lib'], + moduleNameMapper: { + '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + }, +}; + +export default config; diff --git a/packages/shared/jest.config.ts b/packages/shared/jest.config.ts deleted file mode 100644 index f3ba355b8..000000000 --- a/packages/shared/jest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { merge, Config } from '@silverhand/jest-config'; - -const config: Config.InitialOptions = merge({ - roots: ['./src'], -}); - -export default config; diff --git a/packages/shared/package.json b/packages/shared/package.json index 9093e6775..39e8b1601 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,25 +4,35 @@ "main": "lib/index.js", "author": "Silverhand Inc. ", "license": "MPL-2.0", + "type": "module", "files": [ "lib" ], + "exports": { + ".": { + "import": "./lib/index.js" + }, + "./esm": { + "import": "./lib/esm/index.js" + } + }, "publishConfig": { "access": "public" }, "scripts": { "precommit": "lint-staged", "build": "rm -rf lib/ && tsc -p tsconfig.build.json", + "build:test": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "prepack": "pnpm build", - "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" }, "devDependencies": { "@silverhand/eslint-config": "1.3.0", - "@silverhand/jest-config": "1.2.2", "@silverhand/ts-config": "1.2.1", "@types/jest": "^29.1.2", "@types/node": "^16.0.0", @@ -30,7 +40,7 @@ "jest": "^29.1.2", "lint-staged": "^13.0.0", "prettier": "^2.7.1", - "typescript": "^4.7.4" + "typescript": "^4.9.4" }, "engines": { "node": "^16.13.0 || ^18.12.0" @@ -43,7 +53,7 @@ }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { - "@logto/schemas": "workspace:^", + "@logto/schemas": "workspace:*", "@silverhand/essentials": "^1.3.0", "find-up": "^5.0.0", "nanoid": "^3.3.4", diff --git a/packages/shared/src/database/index.ts b/packages/shared/src/database/index.ts index 6d5a6ef49..920534d03 100644 --- a/packages/shared/src/database/index.ts +++ b/packages/shared/src/database/index.ts @@ -1,2 +1,2 @@ -export * from './types'; -export * from './utils'; +export * from './types.js'; +export * from './utils.js'; diff --git a/packages/shared/src/database/utils.test.ts b/packages/shared/src/database/utils.test.ts index 79653f147..9199c1021 100644 --- a/packages/shared/src/database/utils.test.ts +++ b/packages/shared/src/database/utils.test.ts @@ -1,7 +1,7 @@ import { sql } from 'slonik'; import { SqlToken } from 'slonik/dist/src/tokens.js'; -import type { Table } from './types'; +import type { Table } from './types.js'; import { excludeAutoSetFields, autoSetFields, @@ -9,7 +9,9 @@ import { convertToIdentifiers, convertToTimestamp, conditionalSql, -} from './utils'; +} from './utils.js'; + +const { jest } = import.meta; describe('conditionalSql()', () => { it('returns empty sql when value is falsy', () => { diff --git a/packages/shared/src/database/utils.ts b/packages/shared/src/database/utils.ts index 910e8e9b2..0601cf589 100644 --- a/packages/shared/src/database/utils.ts +++ b/packages/shared/src/database/utils.ts @@ -4,7 +4,7 @@ import { notFalsy } from '@silverhand/essentials'; import type { SqlSqlToken, SqlToken, QueryResult, IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; -import type { FieldIdentifiers, Table } from './types'; +import type { FieldIdentifiers, Table } from './types.js'; export const conditionalSql = (value: T, buildSql: (value: Exclude) => SqlSqlToken) => notFalsy(value) ? buildSql(value) : sql``; diff --git a/packages/shared/src/esm/index.ts b/packages/shared/src/esm/index.ts new file mode 100644 index 000000000..c51c2b276 --- /dev/null +++ b/packages/shared/src/esm/index.ts @@ -0,0 +1,2 @@ +export { default as moduleProxy } from './module-proxy.js'; +export * from './mock-esm.js'; diff --git a/packages/shared/src/esm/mock-esm.ts b/packages/shared/src/esm/mock-esm.ts new file mode 100644 index 000000000..05e251405 --- /dev/null +++ b/packages/shared/src/esm/mock-esm.ts @@ -0,0 +1,72 @@ +import path from 'path'; + +const { jest } = import.meta; + +type MockParameters = Parameters<(moduleName: string, factory: () => T) => void>; + +// See https://github.com/sindresorhus/callsites +/* eslint-disable @silverhand/fp/no-mutation */ +const callSites = (): NodeJS.CallSite[] => { + const _prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (_, stack) => stack; + const stack = new Error().stack?.slice(1); // eslint-disable-line unicorn/error-message + Error.prepareStackTrace = _prepareStackTrace; + + // @ts-expect-error ignore the error since it has been replaced with the original stack array + return stack ?? []; +}; +/* eslint-enable @silverhand/fp/no-mutation */ + +// Depth default is 2 since it'll be called by `mockEsmXyz()` in this module. +// Need to trace one level deeper for the original caller. +const resolvePath = (pathOrModule: string, depth = 2): string => { + if (pathOrModule === '@logto/shared') { + return new URL('../../', import.meta.url).pathname; + } + + if (!pathOrModule.startsWith('.')) { + return pathOrModule; + } + + return path.join(path.dirname(callSites()[depth]?.getFileName() ?? ''), pathOrModule); +}; + +export const mockEsmWithActual: (...args: MockParameters) => Promise = async ( + moduleName, + factory +) => { + const resolvedModule = resolvePath(moduleName); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = await import(resolvedModule); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + jest.unstable_mockModule(resolvedModule, () => ({ + ...actual, + ...factory(), + })); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return import(resolvedModule); +}; + +export const mockEsm = (...args: MockParameters) => { + const mocked = args[1](); + jest.unstable_mockModule(resolvePath(args[0]), () => mocked); + + return mocked; +}; + +export const mockEsmDefault = (...args: MockParameters) => { + const mocked = args[1](); + + jest.unstable_mockModule(resolvePath(args[0]), () => ({ default: mocked })); + + return mocked; +}; + +export const pickDefault = async >( + promise: Promise +): Promise => { + const awaited = await promise; + + return awaited.default; +}; diff --git a/packages/shared/src/esm/module-proxy.ts b/packages/shared/src/esm/module-proxy.ts new file mode 100644 index 000000000..c378377d9 --- /dev/null +++ b/packages/shared/src/esm/module-proxy.ts @@ -0,0 +1,17 @@ +const { jest } = import.meta; +// For testing +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment +const proxy: ProxyConstructor = new Proxy( + {}, + { + get(_, name) { + if (name === 'default') { + return proxy; + } + + return jest.fn(); + }, + } +); + +export default proxy; diff --git a/packages/shared/src/include.d/import-meta.d.ts b/packages/shared/src/include.d/import-meta.d.ts new file mode 100644 index 000000000..e016debb5 --- /dev/null +++ b/packages/shared/src/include.d/import-meta.d.ts @@ -0,0 +1,10 @@ +interface ImportMeta { + jest: typeof jest & { + // Almost same as `jest.mock()`, but factory is required + unstable_mockModule: ( + moduleName: string, + factory: () => T, + options?: jest.MockOptions + ) => typeof jest; + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b5963a52c..7bf661541 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,2 @@ -export * from './database'; -export * from './utils'; +export * from './database/index.js'; +export * from './utils/index.js'; diff --git a/packages/shared/src/utils/id.test.ts b/packages/shared/src/utils/id.test.ts index 189b6dd3f..b6cc4d532 100644 --- a/packages/shared/src/utils/id.test.ts +++ b/packages/shared/src/utils/id.test.ts @@ -1,4 +1,4 @@ -import { buildIdGenerator, alphabet } from './id'; +import { buildIdGenerator, alphabet } from './id.js'; describe('id generator', () => { it('should match the input length', () => { diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 3912ce027..8bfcfa7f3 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,3 +1,3 @@ -export * from './id'; -export * from './function'; -export { default as findPackage } from './find-package'; +export * from './id.js'; +export * from './function.js'; +export { default as findPackage } from './find-package.js'; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 00b88e2de..b41cf6355 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -3,10 +3,11 @@ "compilerOptions": { "outDir": "lib", "declaration": true, - "types": ["node", "jest"] + "types": ["node", "jest"], + "moduleResolution": "nodenext", + "module": "esnext" }, "include": [ - "src", - "jest.config.ts" + "src" ] } diff --git a/packages/ui/jest.config.ts b/packages/ui/jest.config.ts index d9c957b98..aa3cd57da 100644 --- a/packages/ui/jest.config.ts +++ b/packages/ui/jest.config.ts @@ -1,11 +1,16 @@ -import { merge, Config } from '@silverhand/jest-config'; +import type { Config } from '@silverhand/jest-config'; +import { merge } from '@silverhand/jest-config'; -const config: Config.InitialOptions = merge({ - testEnvironment: 'jsdom', - setupFilesAfterEnv: ['/src/jest.setup.ts'], - transform: { - '\\.(svg)$': 'jest-transformer-svg', - }, -}); +const config: Config.InitialOptions = { + ...merge({ + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/src/jest.setup.ts'], + transform: { + '\\.(svg)$': 'jest-transformer-svg', + }, + }), + // Will update common config soon + transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto))/)'], +}; export default config; diff --git a/packages/ui/package.json b/packages/ui/package.json index eb60adfbd..b956e54e5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,6 +2,7 @@ "name": "@logto/ui", "version": "1.0.0-beta.17", "license": "MPL-2.0", + "type": "module", "private": true, "scripts": { "precommit": "lint-staged", @@ -16,14 +17,14 @@ "test": "jest" }, "devDependencies": { - "@logto/core-kit": "1.0.0-beta.20", - "@logto/language-kit": "1.0.0-beta.20", - "@logto/phrases": "workspace:^", - "@logto/phrases-ui": "workspace:^", - "@logto/schemas": "workspace:^", - "@parcel/core": "2.7.0", - "@parcel/transformer-sass": "2.7.0", - "@parcel/transformer-svg-react": "2.7.0", + "@logto/core-kit": "1.0.0-beta.28", + "@logto/language-kit": "1.0.0-beta.28", + "@logto/phrases": "workspace:*", + "@logto/phrases-ui": "workspace:*", + "@logto/schemas": "workspace:*", + "@parcel/core": "2.8.0", + "@parcel/transformer-sass": "2.8.0", + "@parcel/transformer-svg-react": "2.8.0", "@peculiar/webcrypto": "^1.3.3", "@silverhand/eslint-config": "1.3.0", "@silverhand/eslint-config-react": "1.3.0", @@ -38,7 +39,7 @@ "@types/react-dom": "^18.0.0", "@types/react-modal": "^3.13.1", "@types/react-router-dom": "^5.3.2", - "camelcase-keys": "^7.0.2", + "camelcase-keys": "^8.0.0", "classnames": "^2.3.1", "color": "^4.2.3", "cross-env": "^7.0.3", @@ -49,10 +50,10 @@ "jest-environment-jsdom": "^29.0.0", "jest-transformer-svg": "^2.0.0", "js-base64": "^3.7.2", - "ky": "^0.31.0", + "ky": "^0.32.0", "libphonenumber-js": "^1.9.49", "lint-staged": "^13.0.0", - "parcel": "2.7.0", + "parcel": "2.8.0", "postcss": "^8.4.6", "postcss-modules": "^4.3.0", "prettier": "^2.7.1", @@ -66,8 +67,9 @@ "react-timer-hook": "^3.0.5", "stylelint": "^14.9.1", "superstruct": "^0.16.0", - "typescript": "^4.7.4", - "use-debounced-loader": "^0.1.1" + "typescript": "^4.9.4", + "use-debounced-loader": "^0.1.1", + "zod": "^3.19.1" }, "engines": { "node": "^16.13.0 || ^18.12.0" diff --git a/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx b/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx index 7f58d0c43..2a73e01d9 100644 --- a/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx +++ b/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx @@ -2,12 +2,12 @@ import type { ReactElement } from 'react'; import { useContext, useEffect } from 'react'; import { PageContext } from '@/hooks/use-page-context'; -import type { SignInExperienceSettings } from '@/types'; +import type { SignInExperienceResponse } from '@/types'; import { mockSignInExperienceSettings } from '../logto'; type Props = { - settings?: SignInExperienceSettings; + settings?: SignInExperienceResponse; children: ReactElement; }; diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index 2af8e92a5..3ddb84131 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -5,10 +5,9 @@ import { ConnectorType, SignInIdentifier, SignInMode, - SignUpIdentifier, } from '@logto/schemas'; -import type { SignInExperienceSettings } from '@/types'; +import type { SignInExperienceResponse } from '@/types'; export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v=4'; export const appHeadline = 'Build user identity in a modern way'; @@ -202,7 +201,7 @@ export const mockSignInExperience: SignInExperience = { fallbackLanguage: 'en', }, signUp: { - identifier: SignUpIdentifier.Username, + identifiers: [SignInIdentifier.Username], password: true, verify: true, }, @@ -213,7 +212,7 @@ export const mockSignInExperience: SignInExperience = { signInMode: SignInMode.SignInAndRegister, }; -export const mockSignInExperienceSettings: SignInExperienceSettings = { +export const mockSignInExperienceSettings: SignInExperienceResponse = { id: mockSignInExperience.id, color: mockSignInExperience.color, branding: mockSignInExperience.branding, @@ -221,7 +220,7 @@ export const mockSignInExperienceSettings: SignInExperienceSettings = { languageInfo: mockSignInExperience.languageInfo, signIn: mockSignInExperience.signIn, signUp: { - methods: [SignInIdentifier.Username], + identifiers: [SignInIdentifier.Username], password: true, verify: true, }, diff --git a/packages/ui/src/components/ConfirmModal/AcModal.tsx b/packages/ui/src/components/ConfirmModal/AcModal.tsx index 0ed4b1645..a019afe90 100644 --- a/packages/ui/src/components/ConfirmModal/AcModal.tsx +++ b/packages/ui/src/components/ConfirmModal/AcModal.tsx @@ -27,6 +27,7 @@ const AcModal = ({ return ( { contentRef.current?.focus(); }} + onRequestClose={onClose} >
{ return (
{children}
diff --git a/packages/ui/src/components/Input/phoneInput.module.scss b/packages/ui/src/components/Input/phoneInput.module.scss index c522e0a21..68ac5b054 100644 --- a/packages/ui/src/components/Input/phoneInput.module.scss +++ b/packages/ui/src/components/Input/phoneInput.module.scss @@ -40,6 +40,11 @@ width: 16px; height: 16px; } + + + input { + // hot fix unknown android bug of input width + width: 0; + } } } diff --git a/packages/ui/src/containers/ConfirmModalProvider/index.tsx b/packages/ui/src/containers/ConfirmModalProvider/index.tsx index 32c51807a..5dd150285 100644 --- a/packages/ui/src/containers/ConfirmModalProvider/index.tsx +++ b/packages/ui/src/containers/ConfirmModalProvider/index.tsx @@ -1,4 +1,5 @@ import type { Nullable } from '@silverhand/essentials'; +import { noop } from '@silverhand/essentials'; import { useState, useRef, useMemo, createContext, useCallback } from 'react'; import type { ModalProps } from '@/components/ConfirmModal'; @@ -25,10 +26,6 @@ type ConfirmModalContextType = { cancel: (data?: unknown) => void; }; -const noop = () => { - throw new Error('Context provider not found'); -}; - export const ConfirmModalContext = createContext({ show: async () => [true], confirm: noop, diff --git a/packages/ui/src/containers/CreateAccount/index.tsx b/packages/ui/src/containers/CreateAccount/index.tsx index 241f84e68..976532b15 100644 --- a/packages/ui/src/containers/CreateAccount/index.tsx +++ b/packages/ui/src/containers/CreateAccount/index.tsx @@ -49,7 +49,7 @@ const CreateAccount = ({ className, autoFocus }: Props) => { const registerErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.username_exists_register': () => { + 'user.username_already_in_use': () => { setFieldErrors((state) => ({ ...state, username: 'username_exists', diff --git a/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts index 9bdab246d..9cc5c47fa 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-continue-set-email-passcode-validation.ts @@ -37,7 +37,7 @@ const useContinueSetEmailPasscodeValidation = (email: string, errorCallback?: () const setEmailErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.email_not_exists': identifierNotExistErrorHandler, + 'user.email_not_exist': identifierNotExistErrorHandler, ...requiredProfileErrorHandler, callback: errorCallback, }), diff --git a/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts index 4156bf4f6..748127309 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-continue-set-sms-passcode-validation.ts @@ -34,7 +34,7 @@ const useContinueSetSmsPasscodeValidation = (phone: string, errorCallback?: () = const setPhoneErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.phone_not_exists': identifierNotExistErrorHandler, + 'user.phone_not_exist': identifierNotExistErrorHandler, ...requiredProfileErrorHandler, callback: errorCallback, }), diff --git a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts index c66fb6c08..3d3ec9dd3 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-email-passcode-validation.ts @@ -22,7 +22,7 @@ const useForgotPasswordEmailPasscodeValidation = (email: string, errorCallback?: const errorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.email_not_exists': identifierNotExistErrorHandler, + 'user.email_not_exist': identifierNotExistErrorHandler, ...sharedErrorHandlers, callback: errorCallback, }), diff --git a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts index 685f67a97..d46c6fd8e 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-forgot-password-sms-passcode-validation.ts @@ -21,7 +21,7 @@ const useForgotPasswordSmsPasscodeValidation = (phone: string, errorCallback?: ( const errorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.phone_not_exists': identifierNotExistErrorHandler, + 'user.phone_not_exist': identifierNotExistErrorHandler, ...sharedErrorHandlers, callback: errorCallback, }), diff --git a/packages/ui/src/containers/PasscodeValidation/use-identifier-error-alert.ts b/packages/ui/src/containers/PasscodeValidation/use-identifier-error-alert.ts index 9c3c4911f..296b7a9a6 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-identifier-error-alert.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-identifier-error-alert.ts @@ -22,7 +22,7 @@ const useIdentifierErrorAlert = ( ModalContent: t( flow === UserFlow.register ? 'description.create_account_id_exists_alert' - : 'description.sign_in_id_does_not_exists_alert', + : 'description.sign_in_id_does_not_exist_alert', { type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`), value, diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts index 0b05aae72..3bb3614d7 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-email-passcode-validation.ts @@ -57,7 +57,7 @@ const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: ( const errorHandlers = useMemo( () => ({ - 'user.email_exists_register': + 'user.email_already_in_use': signInMode === SignInMode.Register ? identifierExistErrorHandler : emailExistSignInErrorHandler, diff --git a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts index 44260ecf6..2a52726a3 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-register-with-sms-passcode-validation.ts @@ -57,7 +57,7 @@ const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: () const errorHandlers = useMemo( () => ({ - 'user.phone_exists_register': + 'user.phone_already_in_use': signInMode === SignInMode.Register ? identifierExistErrorHandler : phoneExistSignInErrorHandler, diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts index fb2696a0b..0844b3bb9 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-email-passcode-validation.ts @@ -39,7 +39,7 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: () const emailNotExistRegisterErrorHandler = useCallback(async () => { const [confirm] = await show({ confirmText: 'action.create', - ModalContent: t('description.sign_in_id_does_not_exists', { + ModalContent: t('description.sign_in_id_does_not_exist', { type: t(`description.email`), value: email, }), @@ -60,7 +60,7 @@ const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: () const errorHandlers = useMemo( () => ({ - 'user.email_not_exists': + 'user.email_not_exist': // Block user auto register if is bind social or sign-in only flow signInMode === SignInMode.SignIn || socialToBind ? identifierNotExistErrorHandler diff --git a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts index b583cfa79..a63f2c3f2 100644 --- a/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts +++ b/packages/ui/src/containers/PasscodeValidation/use-sign-in-with-sms-passcode-validation.ts @@ -39,7 +39,7 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () => const phoneNotExistRegisterErrorHandler = useCallback(async () => { const [confirm] = await show({ confirmText: 'action.create', - ModalContent: t('description.sign_in_id_does_not_exists', { + ModalContent: t('description.sign_in_id_does_not_exist', { type: t(`description.phone_number`), value: phone, }), @@ -60,7 +60,7 @@ const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () => const errorHandlers = useMemo( () => ({ - 'user.phone_not_exists': + 'user.phone_not_exist': // Block user auto register if is bind social or sign-in only flow signInMode === SignInMode.SignIn || socialToBind ? identifierNotExistErrorHandler diff --git a/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts b/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts index 7438e5c17..d558a0af3 100644 --- a/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts +++ b/packages/ui/src/containers/UsernameForm/SetUsername/use-set-username.ts @@ -20,7 +20,7 @@ const useSetUsername = () => { const errorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.username_exists_register': (error) => { + 'user.username_already_in_use': (error) => { setErrorMessage(error.message); }, ...requiredProfileErrorHandler, diff --git a/packages/ui/src/containers/UsernameForm/UsernameRegister/use-username-register.ts b/packages/ui/src/containers/UsernameForm/UsernameRegister/use-username-register.ts index 1fda25827..114728a97 100644 --- a/packages/ui/src/containers/UsernameForm/UsernameRegister/use-username-register.ts +++ b/packages/ui/src/containers/UsernameForm/UsernameRegister/use-username-register.ts @@ -17,7 +17,7 @@ const useUsernameRegister = () => { const errorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.username_exists_register': (error) => { + 'user.username_already_in_use': (error) => { setErrorMessage(error.message); }, }), diff --git a/packages/ui/src/hooks/use-page-context.ts b/packages/ui/src/hooks/use-page-context.ts index 29648b124..ce40853d4 100644 --- a/packages/ui/src/hooks/use-page-context.ts +++ b/packages/ui/src/hooks/use-page-context.ts @@ -1,7 +1,8 @@ +import { noop } from '@silverhand/essentials'; import { useState, useMemo, createContext } from 'react'; import { isMobile } from 'react-device-detect'; -import type { SignInExperienceSettings, Platform, Theme } from '@/types'; +import type { SignInExperienceResponse, Platform, Theme } from '@/types'; export type Context = { theme: Theme; @@ -9,17 +10,13 @@ export type Context = { loading: boolean; platform: Platform; termsAgreement: boolean; - experienceSettings: SignInExperienceSettings | undefined; + experienceSettings: SignInExperienceResponse | undefined; setTheme: React.Dispatch>; setToast: React.Dispatch>; setLoading: React.Dispatch>; setPlatform: React.Dispatch>; setTermsAgreement: React.Dispatch>; - setExperienceSettings: React.Dispatch>; -}; - -const noop = () => { - throw new Error('Context provider not found'); + setExperienceSettings: React.Dispatch>; }; export const PageContext = createContext({ @@ -42,7 +39,7 @@ const usePageContext = () => { const [toast, setToast] = useState(''); const [theme, setTheme] = useState('light'); const [platform, setPlatform] = useState(isMobile ? 'mobile' : 'web'); - const [experienceSettings, setExperienceSettings] = useState(); + const [experienceSettings, setExperienceSettings] = useState(); const [termsAgreement, setTermsAgreement] = useState(false); const context = useMemo( diff --git a/packages/ui/src/hooks/use-preview.ts b/packages/ui/src/hooks/use-preview.ts index bf5b22437..e7e922fdd 100644 --- a/packages/ui/src/hooks/use-preview.ts +++ b/packages/ui/src/hooks/use-preview.ts @@ -6,9 +6,8 @@ import * as styles from '@/containers/AppContent/index.module.scss'; import type { Context } from '@/hooks/use-page-context'; import initI18n from '@/i18n/init'; import { changeLanguage } from '@/i18n/utils'; -import type { SignInExperienceSettings, PreviewConfig } from '@/types'; +import type { SignInExperienceResponse, PreviewConfig } from '@/types'; import { parseQueryParameters } from '@/utils'; -import { signUpIdentifierMap } from '@/utils/sign-in-experience'; import { filterPreviewSocialConnectors } from '@/utils/social-connectors'; const usePreview = (context: Context): [boolean, PreviewConfig?] => { @@ -54,25 +53,19 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => { } const { - signInExperience: { signUp, socialConnectors, color, ...rest }, + signInExperience: { socialConnectors, color, ...rest }, language, mode, platform, isNative, } = previewConfig; - const { identifier, ...signUpSettings } = signUp; - - const experienceSettings: SignInExperienceSettings = { + const experienceSettings: SignInExperienceResponse = { ...rest, color: { ...color, isDarkModeEnabled: false, // Disable theme mode auto detection on preview }, - signUp: { - methods: signUpIdentifierMap[identifier], - ...signUpSettings, - }, socialConnectors: filterPreviewSocialConnectors( isNative ? ConnectorPlatform.Native : ConnectorPlatform.Web, socialConnectors diff --git a/packages/ui/src/hooks/use-required-profile-error-handler.ts b/packages/ui/src/hooks/use-required-profile-error-handler.ts index 2866f1b89..4c1079d2e 100644 --- a/packages/ui/src/hooks/use-required-profile-error-handler.ts +++ b/packages/ui/src/hooks/use-required-profile-error-handler.ts @@ -8,7 +8,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => { const requiredProfileErrorHandler = useMemo( () => ({ - 'user.require_password': () => { + 'user.password_required_in_profile': () => { navigate( { pathname: `/${UserFlow.continue}/password`, @@ -17,7 +17,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => { { replace } ); }, - 'user.require_username': () => { + 'user.username_required_in_profile': () => { navigate( { pathname: `/${UserFlow.continue}/username`, @@ -26,7 +26,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => { { replace } ); }, - 'user.require_email': () => { + 'user.email_required_in_profile': () => { navigate( { pathname: `/${UserFlow.continue}/email`, @@ -35,7 +35,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => { { replace } ); }, - 'user.require_sms': () => { + 'user.phone_required_in_profile': () => { navigate( { pathname: `/${UserFlow.continue}/sms`, @@ -44,7 +44,7 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => { { replace } ); }, - 'user.require_email_or_sms': () => { + 'user.email_or_phone_required_in_profile': () => { navigate( { pathname: `/${UserFlow.continue}/email-or-sms/email`, diff --git a/packages/ui/src/hooks/use-sie.ts b/packages/ui/src/hooks/use-sie.ts index 525b27267..b958fa772 100644 --- a/packages/ui/src/hooks/use-sie.ts +++ b/packages/ui/src/hooks/use-sie.ts @@ -4,10 +4,10 @@ import { PageContext } from './use-page-context'; export const useSieMethods = () => { const { experienceSettings } = useContext(PageContext); - const { methods, password, verify } = experienceSettings?.signUp ?? {}; + const { identifiers, password, verify } = experienceSettings?.signUp ?? {}; return { - signUpMethods: methods ?? [], + signUpMethods: identifiers ?? [], signUpSettings: { password, verify }, signInMethods: experienceSettings?.signIn.methods.filter( diff --git a/packages/ui/src/hooks/use-social-sign-in-listener.ts b/packages/ui/src/hooks/use-social-sign-in-listener.ts index a7b5607a8..b2a091dc6 100644 --- a/packages/ui/src/hooks/use-social-sign-in-listener.ts +++ b/packages/ui/src/hooks/use-social-sign-in-listener.ts @@ -22,7 +22,7 @@ const useSocialSignInListener = () => { const signInWithSocialErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.identity_not_exists': (error) => { + 'user.identity_not_exist': (error) => { // Should not let user register under sign-in only mode if (experienceSettings?.signInMode === SignInMode.SignIn) { setToast(error.message); diff --git a/packages/ui/src/pages/Continue/SetEmail/index.test.tsx b/packages/ui/src/pages/Continue/SetEmail/index.test.tsx index 3bc0cfb54..e7703c272 100644 --- a/packages/ui/src/pages/Continue/SetEmail/index.test.tsx +++ b/packages/ui/src/pages/Continue/SetEmail/index.test.tsx @@ -20,7 +20,10 @@ describe('SetEmail', () => { diff --git a/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts b/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts index 563b33de9..88eb11023 100644 --- a/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts +++ b/packages/ui/src/pages/Continue/SetPassword/use-set-password.ts @@ -17,7 +17,7 @@ const useSetPassword = () => { const errorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.password_exists': async (error) => { + 'user.password_exists_in_profile': async (error) => { await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); navigate(-1); }, diff --git a/packages/ui/src/pages/Continue/SetPhone/index.test.tsx b/packages/ui/src/pages/Continue/SetPhone/index.test.tsx index f27a775ef..9373f761d 100644 --- a/packages/ui/src/pages/Continue/SetPhone/index.test.tsx +++ b/packages/ui/src/pages/Continue/SetPhone/index.test.tsx @@ -24,7 +24,10 @@ describe('SetPhone', () => { diff --git a/packages/ui/src/pages/PasswordRegisterWithUsername/index.test.tsx b/packages/ui/src/pages/PasswordRegisterWithUsername/index.test.tsx index 943362fec..27a2ef291 100644 --- a/packages/ui/src/pages/PasswordRegisterWithUsername/index.test.tsx +++ b/packages/ui/src/pages/PasswordRegisterWithUsername/index.test.tsx @@ -59,7 +59,10 @@ describe('', () => { diff --git a/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts b/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts index 5a2072528..f92794d43 100644 --- a/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts +++ b/packages/ui/src/pages/PasswordRegisterWithUsername/use-username-password-register.ts @@ -16,7 +16,7 @@ const useUsernamePasswordRegister = () => { const resetPasswordErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.username_exists_register': async (error) => { + 'user.username_already_in_use': async (error) => { await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); navigate(-1); }, diff --git a/packages/ui/src/pages/Register/index.test.tsx b/packages/ui/src/pages/Register/index.test.tsx index 34be5a739..15949d049 100644 --- a/packages/ui/src/pages/Register/index.test.tsx +++ b/packages/ui/src/pages/Register/index.test.tsx @@ -32,7 +32,10 @@ describe('', () => { @@ -49,7 +52,10 @@ describe('', () => { @@ -68,7 +74,7 @@ describe('', () => { ...mockSignInExperienceSettings, signUp: { ...mockSignInExperienceSettings.signUp, - methods: [SignInIdentifier.Email, SignInIdentifier.Sms], + identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms], }, }} > @@ -86,7 +92,7 @@ describe('', () => { diff --git a/packages/ui/src/pages/SecondaryRegister/index.test.tsx b/packages/ui/src/pages/SecondaryRegister/index.test.tsx index 5344c6e7d..cffc044ac 100644 --- a/packages/ui/src/pages/SecondaryRegister/index.test.tsx +++ b/packages/ui/src/pages/SecondaryRegister/index.test.tsx @@ -24,7 +24,7 @@ describe('', () => { ...mockSignInExperienceSettings, signUp: { ...mockSignInExperienceSettings.signUp, - methods: [SignInIdentifier.Sms], + identifiers: [SignInIdentifier.Sms], }, }} > @@ -51,7 +51,7 @@ describe('', () => { ...mockSignInExperienceSettings, signUp: { ...mockSignInExperienceSettings.signUp, - methods: [SignInIdentifier.Email], + identifiers: [SignInIdentifier.Email], }, }} > @@ -115,7 +115,7 @@ describe('', () => { settings={{ ...mockSignInExperienceSettings, signUp: { - methods: [SignInIdentifier.Email], + identifiers: [SignInIdentifier.Email], password: true, verify: false, }, diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index ee2ade2d9..fe78f7c6c 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -1,9 +1,4 @@ -import type { - SignInExperience, - ConnectorMetadata, - AppearanceMode, - SignInIdentifier, -} from '@logto/schemas'; +import type { SignInExperience, ConnectorMetadata, AppearanceMode } from '@logto/schemas'; export enum UserFlow { signIn = 'sign-in', @@ -23,12 +18,8 @@ export type Platform = 'web' | 'mobile'; // TODO: @simeng, @sijie, @charles should we combine this with admin console? export type Theme = 'dark' | 'light'; -// Omit signInMethods property since it is deprecated, // Omit socialSignInConnectorTargets since it is being translated into socialConnectors -export type SignInExperienceResponse = Omit< - SignInExperience, - 'signInMethods' | 'socialSignInConnectorTargets' -> & { +export type SignInExperienceResponse = Omit & { socialConnectors: ConnectorMetadata[]; notification?: string; forgotPassword: { @@ -37,12 +28,6 @@ export type SignInExperienceResponse = Omit< }; }; -export type SignInExperienceSettings = Omit & { - signUp: Omit & { - methods: SignInIdentifier[]; - }; -}; - export enum ConfirmModalMessage { SHOW_TERMS_DETAIL_MODAL = 'SHOW_TERMS_DETAIL_MODAL', } diff --git a/packages/ui/src/utils/sign-in-experience.test.ts b/packages/ui/src/utils/sign-in-experience.test.ts index b0e709ce9..8c7be2dc3 100644 --- a/packages/ui/src/utils/sign-in-experience.test.ts +++ b/packages/ui/src/utils/sign-in-experience.test.ts @@ -17,7 +17,7 @@ describe('getSignInExperienceSettings', () => { expect(settings.branding).toEqual(mockSignInExperience.branding); expect(settings.languageInfo).toEqual(mockSignInExperience.languageInfo); expect(settings.termsOfUse).toEqual(mockSignInExperience.termsOfUse); - expect(settings.signUp.methods).toContain('username'); + expect(settings.signUp.identifiers).toContain('username'); expect(settings.signIn.methods).toHaveLength(3); }); }); diff --git a/packages/ui/src/utils/sign-in-experience.ts b/packages/ui/src/utils/sign-in-experience.ts index 331cba329..8b34866cc 100644 --- a/packages/ui/src/utils/sign-in-experience.ts +++ b/packages/ui/src/utils/sign-in-experience.ts @@ -3,37 +3,24 @@ * Remove this once we have a better way to get the sign in experience through SSR */ -import { SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; import { getSignInExperience } from '@/apis/settings'; -import type { SignInExperienceSettings, SignInExperienceResponse } from '@/types'; +import type { SignInExperienceResponse } from '@/types'; import { filterSocialConnectors } from '@/utils/social-connectors'; -export const signUpIdentifierMap: Record = { - [SignUpIdentifier.Username]: [SignInIdentifier.Username], - [SignUpIdentifier.Email]: [SignInIdentifier.Email], - [SignUpIdentifier.Sms]: [SignInIdentifier.Sms], - [SignUpIdentifier.EmailOrSms]: [SignInIdentifier.Email, SignInIdentifier.Sms], - [SignUpIdentifier.None]: [], -}; - const parseSignInExperienceResponse = ( response: SignInExperienceResponse -): SignInExperienceSettings => { - const { socialConnectors, signUp, ...rest } = response; - const { identifier, ...signUpSettings } = signUp; +): SignInExperienceResponse => { + const { socialConnectors, ...rest } = response; return { ...rest, socialConnectors: filterSocialConnectors(socialConnectors), - signUp: { - methods: signUpIdentifierMap[identifier], - ...signUpSettings, - }, }; }; -export const getSignInExperienceSettings = async (): Promise => { +export const getSignInExperienceSettings = async (): Promise => { const response = await getSignInExperience(); return parseSignInExperienceResponse(response); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae225811e..582774ba1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: '@commitlint/types': ^17.0.0 '@logto/cli': ^1.0.0-beta.10 husky: ^8.0.0 - typescript: ^4.7.4 + typescript: ^4.9.4 dependencies: '@logto/cli': link:packages/cli devDependencies: @@ -19,64 +19,64 @@ importers: '@commitlint/config-conventional': 17.0.0 '@commitlint/types': 17.0.0 husky: 8.0.1 - typescript: 4.7.4 + typescript: 4.9.4 packages/cli: specifiers: - '@logto/schemas': workspace:^ - '@logto/shared': workspace:^ + '@logto/schemas': workspace:* + '@logto/shared': workspace:* '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^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 - chalk: ^4.1.2 + chalk: ^5.0.0 decamelize: ^5.0.0 dotenv: ^16.0.0 eslint: ^8.21.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 - jest: ^29.1.2 + jest: ^29.3.1 lint-staged: ^13.0.0 nanoid: ^3.3.4 - ora: ^5.0.0 + ora: ^6.1.2 p-retry: ^4.6.1 prettier: ^2.7.1 rimraf: ^3.0.2 roarr: ^7.11.0 - semver: ^7.3.7 + semver: ^7.3.8 + sinon: ^15.0.0 slonik: ^30.0.0 slonik-interceptor-preset: ^1.2.10 slonik-sql-tag-raw: ^1.1.4 tar: ^6.1.11 - ts-node: ^10.9.1 - typescript: ^4.7.4 + typescript: ^4.9.4 yargs: ^17.6.0 zod: ^3.19.1 dependencies: '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 1.3.0 - chalk: 4.1.2 + chalk: 5.1.2 decamelize: 5.0.1 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.4.1 + ora: 6.1.2 p-retry: 4.6.1 roarr: 7.11.0 - semver: 7.3.7 + semver: 7.3.8 slonik: 30.1.2 slonik-interceptor-preset: 1.2.10 slonik-sql-tag-raw: 1.1.4_roarr@7.11.0+slonik@30.1.2 @@ -84,38 +84,38 @@ importers: yargs: 17.6.0 zod: 3.19.1 devDependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 '@types/fs-extra': 9.0.13 '@types/inquirer': 8.2.1 '@types/jest': 29.1.2 '@types/node': 16.11.12 '@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_k5ytkvaprncdyzidqqws5bqksq + jest: 29.3.1_@types+node@16.11.12 lint-staged: 13.0.0 prettier: 2.7.1 rimraf: 3.0.2 - ts-node: 10.9.1_ccwudyfw5se7hgalwgkzhn2yp4 - typescript: 4.7.4 + sinon: 15.0.0 + typescript: 4.9.4 packages/console: specifiers: '@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 @@ -144,12 +144,12 @@ importers: 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 @@ -173,26 +173,26 @@ importers: 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 devDependencies: '@fontsource/roboto-mono': 4.5.7 - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui - '@logto/react': 1.0.0-beta.13_react@18.2.0 + '@logto/react': 1.0.0-beta.14_react@18.2.0 '@logto/schemas': link:../schemas '@mdx-js/react': 1.6.22_react@18.2.0 - '@parcel/core': 2.7.0 - '@parcel/transformer-mdx': 2.7.0_qpbak7zubdfpxpruk62f7gjw7u - '@parcel/transformer-sass': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-svg-react': 2.7.0_@parcel+core@2.7.0 - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/eslint-config-react': 1.3.0_3jdvf2aalbcoibv3m53iflhmym + '@parcel/core': 2.8.0 + '@parcel/transformer-mdx': 2.8.0_smofjnbzoy3mcjjin2zix4yahi + '@parcel/transformer-sass': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-svg-react': 2.8.0_@parcel+core@2.8.0 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/eslint-config-react': 1.3.0_pzm7kshjahdwz2kcmmatnemr54 '@silverhand/essentials': 1.3.0 - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 - '@silverhand/ts-config-react': 1.2.1_typescript@4.7.4 + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 + '@silverhand/ts-config-react': 1.2.1_typescript@4.9.4 '@tsconfig/docusaurus': 1.0.5 '@types/color': 3.0.3 '@types/lodash.get': 4.4.7 @@ -216,12 +216,12 @@ importers: history: 5.3.0 i18next: 21.8.16 i18next-browser-languagedetector: 6.1.4 - ky: 0.31.0 + ky: 0.32.2 lint-staged: 13.0.0 lodash.get: 4.4.2 lodash.kebabcase: 4.1.1 - nanoid: 3.3.1 - parcel: 2.7.0_postcss@8.4.6 + nanoid: 3.3.4 + parcel: 2.8.0_postcss@8.4.6 postcss: 8.4.6 postcss-modules: 4.3.0_postcss@8.4.6 prettier: 2.7.1 @@ -245,23 +245,21 @@ importers: snake-case: 3.0.4 stylelint: 14.9.1 swr: 1.3.0_react@18.2.0 - typescript: 4.7.4 + typescript: 4.9.4 zod: 3.19.1 packages/core: specifiers: - '@logto/cli': workspace:^ - '@logto/connector-kit': 1.0.0-beta.27 - '@logto/core-kit': ^1.0.0-beta.18 - '@logto/language-kit': 1.0.0-beta.20 - '@logto/phrases': workspace:^ - '@logto/phrases-ui': workspace:^ - '@logto/schemas': workspace:^ - '@logto/shared': workspace:^ - '@shopify/jest-koa-mocks': ^5.0.0 + '@logto/cli': workspace:* + '@logto/connector-kit': 1.0.0-beta.28 + '@logto/core-kit': 1.0.0-beta.28 + '@logto/language-kit': 1.0.0-beta.28 + '@logto/phrases': workspace:* + '@logto/phrases-ui': workspace:* + '@logto/schemas': workspace:* + '@logto/shared': workspace:* '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^1.3.0 - '@silverhand/jest-config': 1.2.2 '@silverhand/ts-config': 1.2.1 '@types/debug': ^4.1.7 '@types/etag': ^1.8.1 @@ -277,8 +275,9 @@ importers: '@types/lodash.pick': ^4.4.6 '@types/node': ^16.0.0 '@types/oidc-provider': ^7.12.0 + '@types/sinon': ^10.0.13 '@types/supertest': ^2.0.11 - chalk: ^4 + chalk: ^5.0.0 clean-deep: ^3.4.0 copyfiles: ^2.4.1 date-fns: ^2.29.3 @@ -290,14 +289,13 @@ importers: etag: ^1.8.1 find-up: ^5.0.0 fs-extra: ^10.1.0 - got: ^11.8.5 hash-wasm: ^4.9.0 http-errors: ^1.6.3 i18next: ^21.8.16 iconv-lite: 0.6.3 jest: ^29.1.2 jest-matcher-specific-error: ^1.0.0 - jose: ^4.0.0 + jose: ^4.11.0 js-yaml: ^4.1.0 koa: ^2.13.1 koa-body: ^5.0.0 @@ -309,9 +307,8 @@ importers: koa-send: ^5.0.1 lint-staged: ^13.0.0 lodash.pick: ^4.4.0 - module-alias: ^2.2.2 - nanoid: ^3.1.23 - nock: ^13.2.2 + nanoid: ^3.3.4 + node-mocks-http: ^1.12.1 nodemon: ^2.0.19 oidc-provider: ^7.13.0 openapi-types: ^12.0.0 @@ -319,25 +316,26 @@ importers: prettier: ^2.7.1 query-string: ^7.0.1 roarr: ^7.11.0 + sinon: ^15.0.0 slonik: ^30.0.0 slonik-interceptor-preset: ^1.2.10 slonik-sql-tag-raw: ^1.1.4 snake-case: ^3.0.4 - snakecase-keys: ^5.1.0 + snakecase-keys: ^5.4.4 supertest: ^6.2.2 - typescript: ^4.7.4 + typescript: ^4.9.4 zod: ^3.19.1 dependencies: '@logto/cli': link:../cli - '@logto/connector-kit': 1.0.0-beta.27_zod@3.19.1 - '@logto/core-kit': 1.0.0-beta.18 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/connector-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 1.3.0 - chalk: 4.1.2 + chalk: 5.1.2 clean-deep: 3.4.0 date-fns: 2.29.3 debug: 4.3.4 @@ -347,11 +345,10 @@ importers: etag: 1.8.1 find-up: 5.0.0 fs-extra: 10.1.0 - got: 11.8.5 hash-wasm: 4.9.0 i18next: 21.8.16 iconv-lite: 0.6.3 - jose: 4.6.0 + jose: 4.11.0 js-yaml: 4.1.0 koa: 2.13.4 koa-body: 5.0.0 @@ -362,8 +359,7 @@ importers: koa-router: 12.0.0 koa-send: 5.0.1 lodash.pick: 4.4.0 - module-alias: 2.2.2 - nanoid: 3.1.30 + nanoid: 3.3.4 oidc-provider: 7.13.0 p-retry: 4.6.1 query-string: 7.0.1 @@ -372,13 +368,11 @@ importers: slonik-interceptor-preset: 1.2.10 slonik-sql-tag-raw: 1.1.4_roarr@7.11.0+slonik@30.1.2 snake-case: 3.0.4 - snakecase-keys: 5.1.2 + snakecase-keys: 5.4.4 zod: 3.19.1 devDependencies: - '@shopify/jest-koa-mocks': 5.0.0 - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 '@types/debug': 4.1.7 '@types/etag': 1.8.1 '@types/fs-extra': 9.0.13 @@ -393,6 +387,7 @@ importers: '@types/lodash.pick': 4.4.6 '@types/node': 16.11.12 '@types/oidc-provider': 7.12.0 + '@types/sinon': 10.0.13 '@types/supertest': 2.0.11 copyfiles: 2.4.1 eslint: 8.21.0 @@ -400,28 +395,29 @@ importers: jest: 29.1.2_@types+node@16.11.12 jest-matcher-specific-error: 1.0.0 lint-staged: 13.0.0 - nock: 13.2.2 + node-mocks-http: 1.12.1 nodemon: 2.0.19 openapi-types: 12.0.0 prettier: 2.7.1 + sinon: 15.0.0 supertest: 6.2.2 - typescript: 4.7.4 + typescript: 4.9.4 packages/create: specifiers: - '@logto/cli': workspace:^ + '@logto/cli': workspace:* dependencies: '@logto/cli': link:../cli packages/demo-app: specifiers: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 - '@logto/phrases': workspace:^ - '@logto/react': 1.0.0-beta.13 - '@logto/schemas': workspace:^ - '@parcel/core': 2.7.0 - '@parcel/transformer-sass': 2.7.0 + '@logto/core-kit': 1.0.0-beta.28 + '@logto/language-kit': 1.0.0-beta.28 + '@logto/phrases': workspace:* + '@logto/react': 1.0.0-beta.14 + '@logto/schemas': workspace:* + '@parcel/core': 2.8.0 + '@parcel/transformer-sass': 2.8.0 '@silverhand/eslint-config': 1.3.0 '@silverhand/eslint-config-react': 1.3.0 '@silverhand/ts-config': 1.2.1 @@ -433,26 +429,27 @@ importers: i18next: ^21.8.16 i18next-browser-languagedetector: ^6.1.4 lint-staged: ^13.0.0 - parcel: 2.7.0 + parcel: 2.8.0 postcss: ^8.4.6 prettier: ^2.7.1 react: ^18.0.0 react-dom: ^18.0.0 react-i18next: ^11.18.3 stylelint: ^14.9.1 - typescript: ^4.7.4 + typescript: ^4.9.4 + zod: ^3.19.1 devDependencies: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@logto/phrases': link:../phrases - '@logto/react': 1.0.0-beta.13_react@18.2.0 + '@logto/react': 1.0.0-beta.14_react@18.2.0 '@logto/schemas': link:../schemas - '@parcel/core': 2.7.0 - '@parcel/transformer-sass': 2.7.0_@parcel+core@2.7.0 - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/eslint-config-react': 1.3.0_qoomm4vc6ijs52fnjlal4yoenm - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 - '@silverhand/ts-config-react': 1.2.1_typescript@4.7.4 + '@parcel/core': 2.8.0 + '@parcel/transformer-sass': 2.8.0_@parcel+core@2.8.0 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/eslint-config-react': 1.3.0_hwxyoluj7tfktess7f4itjwcee + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 + '@silverhand/ts-config-react': 1.2.1_typescript@4.9.4 '@types/react': 18.0.15 '@types/react-dom': 18.0.6 cross-env: 7.0.3 @@ -460,32 +457,32 @@ importers: i18next: 21.8.16 i18next-browser-languagedetector: 6.1.4 lint-staged: 13.0.0 - parcel: 2.7.0_postcss@8.4.14 + parcel: 2.8.0_postcss@8.4.14 postcss: 8.4.14 prettier: 2.7.1 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 react-i18next: 11.18.3_shxxmfhtk2bc4pbx5cyq3uoph4 stylelint: 14.9.1 - typescript: 4.7.4 + typescript: 4.9.4 + zod: 3.19.1 packages/integration-tests: specifiers: '@jest/types': ^29.1.2 - '@logto/js': 1.0.0-beta.13 - '@logto/node': 1.0.0-beta.13 - '@logto/schemas': workspace:^ + '@logto/js': 1.0.0-beta.14 + '@logto/node': 1.0.0-beta.14 + '@logto/schemas': workspace:* '@peculiar/webcrypto': ^1.3.3 '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^1.3.0 - '@silverhand/jest-config': 1.2.2 '@silverhand/ts-config': 1.2.1 '@types/jest': ^29.1.2 '@types/jest-environment-puppeteer': ^5.0.2 '@types/node': ^16.0.0 dotenv: ^16.0.0 eslint: ^8.21.0 - got: ^11.8.5 + got: ^12.5.3 jest: ^29.1.2 jest-puppeteer: ^6.1.1 node-fetch: ^2.6.7 @@ -494,25 +491,23 @@ importers: prettier: ^2.7.1 puppeteer: ^19.0.0 text-encoder: ^0.0.4 - ts-node: ^10.9.1 - typescript: ^4.7.4 + typescript: ^4.9.4 devDependencies: '@jest/types': 29.1.2 - '@logto/js': 1.0.0-beta.13 - '@logto/node': 1.0.0-beta.13 + '@logto/js': 1.0.0-beta.14 + '@logto/node': 1.0.0-beta.14 '@logto/schemas': link:../schemas '@peculiar/webcrypto': 1.3.3 - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa '@silverhand/essentials': 1.3.0 - '@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 '@types/jest': 29.1.2 '@types/jest-environment-puppeteer': 5.0.2 '@types/node': 16.11.12 dotenv: 16.0.0 eslint: 8.21.0 - got: 11.8.5 - jest: 29.1.2_k5ytkvaprncdyzidqqws5bqksq + got: 12.5.3 + jest: 29.1.2_@types+node@16.11.12 jest-puppeteer: 6.1.1_puppeteer@19.2.2 node-fetch: 2.6.7 openapi-schema-validator: 12.0.0 @@ -520,75 +515,73 @@ importers: prettier: 2.7.1 puppeteer: 19.2.2 text-encoder: 0.0.4 - ts-node: 10.9.1_ccwudyfw5se7hgalwgkzhn2yp4 - typescript: 4.7.4 + typescript: 4.9.4 packages/phrases: specifiers: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28 + '@logto/language-kit': 1.0.0-beta.28 '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^1.3.0 '@silverhand/ts-config': 1.2.1 eslint: ^8.21.0 lint-staged: ^13.0.0 prettier: ^2.7.1 - typescript: ^4.7.4 + typescript: ^4.9.4 zod: ^3.19.1 dependencies: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@silverhand/essentials': 1.3.0 zod: 3.19.1 devDependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 eslint: 8.21.0 lint-staged: 13.0.0 prettier: 2.7.1 - typescript: 4.7.4 + typescript: 4.9.4 packages/phrases-ui: specifiers: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28 + '@logto/language-kit': 1.0.0-beta.28 '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^1.3.0 '@silverhand/ts-config': 1.2.1 eslint: ^8.21.0 lint-staged: ^13.0.0 prettier: ^2.7.1 - typescript: ^4.7.4 + typescript: ^4.9.4 zod: ^3.19.1 dependencies: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@silverhand/essentials': 1.3.0 zod: 3.19.1 devDependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 eslint: 8.21.0 lint-staged: 13.0.0 prettier: 2.7.1 - typescript: 4.7.4 + typescript: 4.9.4 packages/schemas: specifiers: - '@logto/connector-kit': 1.0.0-beta.27 - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 - '@logto/phrases': workspace:^ - '@logto/phrases-ui': workspace:^ + '@logto/connector-kit': 1.0.0-beta.28 + '@logto/core-kit': 1.0.0-beta.28 + '@logto/language-kit': 1.0.0-beta.28 + '@logto/phrases': workspace:* + '@logto/phrases-ui': workspace:* '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^1.3.0 - '@silverhand/jest-config': 1.2.2 '@silverhand/ts-config': 1.2.1 '@types/jest': ^29.1.2 '@types/lodash.uniq': ^4.5.6 '@types/node': ^16.0.0 '@types/pluralize': ^0.0.29 - camelcase: ^6.2.0 + camelcase: ^7.0.0 eslint: ^8.21.0 jest: ^29.1.2 lint-staged: ^13.0.0 @@ -596,42 +589,38 @@ importers: pluralize: ^8.0.0 prettier: ^2.7.1 slonik: ^30.0.0 - ts-node: ^10.9.1 - typescript: ^4.7.4 + typescript: ^4.9.4 zod: ^3.19.1 dependencies: - '@logto/connector-kit': 1.0.0-beta.27_zod@3.19.1 - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/connector-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui zod: 3.19.1 devDependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa '@silverhand/essentials': 1.3.0 - '@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 '@types/jest': 29.1.2 '@types/lodash.uniq': 4.5.6 '@types/node': 16.11.12 '@types/pluralize': 0.0.29 - camelcase: 6.2.1 + camelcase: 7.0.0 eslint: 8.21.0 - jest: 29.1.2_k5ytkvaprncdyzidqqws5bqksq + jest: 29.1.2_@types+node@16.11.12 lint-staged: 13.0.0 lodash.uniq: 4.5.0 pluralize: 8.0.0 prettier: 2.7.1 slonik: 30.1.2 - ts-node: 10.9.1_ccwudyfw5se7hgalwgkzhn2yp4 - typescript: 4.7.4 + typescript: 4.9.4 packages/shared: specifiers: - '@logto/schemas': workspace:^ + '@logto/schemas': workspace:* '@silverhand/eslint-config': 1.3.0 '@silverhand/essentials': ^1.3.0 - '@silverhand/jest-config': 1.2.2 '@silverhand/ts-config': 1.2.1 '@types/jest': ^29.1.2 '@types/node': ^16.0.0 @@ -642,7 +631,7 @@ importers: nanoid: ^3.3.4 prettier: ^2.7.1 slonik: ^30.0.0 - typescript: ^4.7.4 + typescript: ^4.9.4 dependencies: '@logto/schemas': link:../schemas '@silverhand/essentials': 1.3.0 @@ -650,27 +639,26 @@ importers: nanoid: 3.3.4 slonik: 30.1.2 devDependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 '@types/jest': 29.1.2 '@types/node': 16.11.12 eslint: 8.21.0 jest: 29.1.2_@types+node@16.11.12 lint-staged: 13.0.0 prettier: 2.7.1 - typescript: 4.7.4 + typescript: 4.9.4 packages/ui: specifiers: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 - '@logto/phrases': workspace:^ - '@logto/phrases-ui': workspace:^ - '@logto/schemas': workspace:^ - '@parcel/core': 2.7.0 - '@parcel/transformer-sass': 2.7.0 - '@parcel/transformer-svg-react': 2.7.0 + '@logto/core-kit': 1.0.0-beta.28 + '@logto/language-kit': 1.0.0-beta.28 + '@logto/phrases': workspace:* + '@logto/phrases-ui': workspace:* + '@logto/schemas': workspace:* + '@parcel/core': 2.8.0 + '@parcel/transformer-sass': 2.8.0 + '@parcel/transformer-svg-react': 2.8.0 '@peculiar/webcrypto': ^1.3.3 '@silverhand/eslint-config': 1.3.0 '@silverhand/eslint-config-react': 1.3.0 @@ -685,7 +673,7 @@ importers: '@types/react-dom': ^18.0.0 '@types/react-modal': ^3.13.1 '@types/react-router-dom': ^5.3.2 - camelcase-keys: ^7.0.2 + camelcase-keys: ^8.0.0 classnames: ^2.3.1 color: ^4.2.3 cross-env: ^7.0.3 @@ -696,10 +684,10 @@ importers: jest-environment-jsdom: ^29.0.0 jest-transformer-svg: ^2.0.0 js-base64: ^3.7.2 - ky: ^0.31.0 + ky: ^0.32.0 libphonenumber-js: ^1.9.49 lint-staged: ^13.0.0 - parcel: 2.7.0 + parcel: 2.8.0 postcss: ^8.4.6 postcss-modules: ^4.3.0 prettier: ^2.7.1 @@ -713,24 +701,25 @@ importers: react-timer-hook: ^3.0.5 stylelint: ^14.9.1 superstruct: ^0.16.0 - typescript: ^4.7.4 + typescript: ^4.9.4 use-debounced-loader: ^0.1.1 + zod: ^3.19.1 devDependencies: - '@logto/core-kit': 1.0.0-beta.20 - '@logto/language-kit': 1.0.0-beta.20 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui '@logto/schemas': link:../schemas - '@parcel/core': 2.7.0 - '@parcel/transformer-sass': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-svg-react': 2.7.0_@parcel+core@2.7.0 + '@parcel/core': 2.8.0 + '@parcel/transformer-sass': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-svg-react': 2.8.0_@parcel+core@2.8.0 '@peculiar/webcrypto': 1.3.3 - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - '@silverhand/eslint-config-react': 1.3.0_3jdvf2aalbcoibv3m53iflhmym + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + '@silverhand/eslint-config-react': 1.3.0_pzm7kshjahdwz2kcmmatnemr54 '@silverhand/essentials': 1.3.0 - '@silverhand/jest-config': 1.2.2_zapogttls25djihwjkusccjjym - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 - '@silverhand/ts-config-react': 1.2.1_typescript@4.7.4 + '@silverhand/jest-config': 1.2.2_ky6c64xxalg2hsll4xx3evq2dy + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 + '@silverhand/ts-config-react': 1.2.1_typescript@4.9.4 '@testing-library/react': 13.3.0_biqbaboplfbrettd7655fr4n2y '@types/color': 3.0.3 '@types/jest': 29.1.2 @@ -738,7 +727,7 @@ importers: '@types/react-dom': 18.0.6 '@types/react-modal': 3.13.1 '@types/react-router-dom': 5.3.2 - camelcase-keys: 7.0.2 + camelcase-keys: 8.0.2 classnames: 2.3.1 color: 4.2.3 cross-env: 7.0.3 @@ -749,10 +738,10 @@ importers: jest-environment-jsdom: 29.2.2 jest-transformer-svg: 2.0.0_jest@29.1.2+react@18.2.0 js-base64: 3.7.2 - ky: 0.31.0 + ky: 0.32.2 libphonenumber-js: 1.9.49 lint-staged: 13.0.0 - parcel: 2.7.0_postcss@8.4.6 + parcel: 2.8.0_postcss@8.4.6 postcss: 8.4.6 postcss-modules: 4.3.0_postcss@8.4.6 prettier: 2.7.1 @@ -766,18 +755,12 @@ importers: react-timer-hook: 3.0.5_biqbaboplfbrettd7655fr4n2y stylelint: 14.9.1 superstruct: 0.16.0 - typescript: 4.7.4 + typescript: 4.9.4 use-debounced-loader: 0.1.1_react@18.2.0 + zod: 3.19.1 packages: - /@ampproject/remapping/2.1.2: - resolution: {integrity: sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/trace-mapping': 0.3.15 - dev: true - /@ampproject/remapping/2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -799,13 +782,13 @@ packages: dependencies: '@babel/highlight': 7.18.6 - /@babel/compat-data/7.17.7: - resolution: {integrity: sha512-p8pdE6j0a29TNGebNm7NzYZWB3xVZJBZ7XGs42uAKzQo8VQ3F0By/cQCtUEABwIqw5zo6WA4NbmxsfzADzMKnQ==} + /@babel/compat-data/7.19.4: + resolution: {integrity: sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw==} engines: {node: '>=6.9.0'} dev: true - /@babel/compat-data/7.19.4: - resolution: {integrity: sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw==} + /@babel/compat-data/7.20.1: + resolution: {integrity: sha512-EWZ4mE2diW3QALKvDMiXnbZpRvlj+nayZ112nK93SnhqOtpdsbVD4W+2tEoT3YNBAG9RBR0ISY758ZkOgsn6pQ==} engines: {node: '>=6.9.0'} dev: true @@ -814,14 +797,14 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.18.6 - '@babel/generator': 7.17.9 - '@babel/helper-module-transforms': 7.17.7 - '@babel/helpers': 7.17.9 - '@babel/parser': 7.18.3 - '@babel/template': 7.16.7 - '@babel/traverse': 7.17.9 - '@babel/types': 7.18.2 - convert-source-map: 1.8.0 + '@babel/generator': 7.20.4 + '@babel/helper-module-transforms': 7.20.2 + '@babel/helpers': 7.20.1 + '@babel/parser': 7.20.3 + '@babel/template': 7.18.10 + '@babel/traverse': 7.20.1 + '@babel/types': 7.20.2 + convert-source-map: 1.9.0 debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.1 @@ -833,29 +816,6 @@ packages: - supports-color dev: true - /@babel/core/7.17.9: - resolution: {integrity: sha512-5ug+SfZCpDAkVp9SFIZAzlW18rlzsOcJGaetCjkySnrXXDUw9AR8cDUm1iByTmdWM6yxX6/zycaV76w3YTF2gw==} - engines: {node: '>=6.9.0'} - dependencies: - '@ampproject/remapping': 2.1.2 - '@babel/code-frame': 7.18.6 - '@babel/generator': 7.17.9 - '@babel/helper-compilation-targets': 7.17.7_@babel+core@7.17.9 - '@babel/helper-module-transforms': 7.17.7 - '@babel/helpers': 7.17.9 - '@babel/parser': 7.18.3 - '@babel/template': 7.16.7 - '@babel/traverse': 7.17.9 - '@babel/types': 7.18.2 - convert-source-map: 1.8.0 - debug: 4.3.4 - gensync: 1.0.0-beta.2 - json5: 2.2.1 - semver: 6.3.0 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/core/7.19.3: resolution: {integrity: sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==} engines: {node: '>=6.9.0'} @@ -879,13 +839,27 @@ packages: - supports-color dev: true - /@babel/generator/7.17.9: - resolution: {integrity: sha512-rAdDousTwxbIxbz5I7GEQ3lUip+xVCXooZNbsydCWs3xA7ZsYOv+CFRdzGxRX78BmQHu9B1Eso59AOZQOJDEdQ==} + /@babel/core/7.20.2: + resolution: {integrity: sha512-w7DbG8DtMrJcFOi4VrLm+8QM4az8Mo+PuLBKLp2zrYRCow8W/f9xiXm5sN53C8HksCyDQwCKha9JiDoIyPjT2g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.2 - jsesc: 2.5.2 - source-map: 0.5.7 + '@ampproject/remapping': 2.2.0 + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.20.4 + '@babel/helper-compilation-targets': 7.20.0_@babel+core@7.20.2 + '@babel/helper-module-transforms': 7.20.2 + '@babel/helpers': 7.20.1 + '@babel/parser': 7.20.3 + '@babel/template': 7.18.10 + '@babel/traverse': 7.20.1 + '@babel/types': 7.20.2 + convert-source-map: 1.9.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.1 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color dev: true /@babel/generator/7.19.5: @@ -897,17 +871,13 @@ packages: jsesc: 2.5.2 dev: true - /@babel/helper-compilation-targets/7.17.7_@babel+core@7.17.9: - resolution: {integrity: sha512-UFzlz2jjd8kroj0hmCFV5zr+tQPi1dpC2cRsDV/3IEW8bJfCPrPpmcSN6ZS8RqIq4LXcmpipCQFPddyFA5Yc7w==} + /@babel/generator/7.20.4: + resolution: {integrity: sha512-luCf7yk/cm7yab6CAW1aiFnmEfBJplb/JojV56MYEK7ziWfGmFlTfmL9Ehwfy4gFhbjBfWO1wj7/TuSbVNEEtA==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 dependencies: - '@babel/compat-data': 7.17.7 - '@babel/core': 7.17.9 - '@babel/helper-validator-option': 7.16.7 - browserslist: 4.20.3 - semver: 6.3.0 + '@babel/types': 7.20.2 + '@jridgewell/gen-mapping': 0.3.2 + jsesc: 2.5.2 dev: true /@babel/helper-compilation-targets/7.19.3_@babel+core@7.19.3: @@ -923,11 +893,17 @@ packages: semver: 6.3.0 dev: true - /@babel/helper-environment-visitor/7.16.7: - resolution: {integrity: sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==} + /@babel/helper-compilation-targets/7.20.0_@babel+core@7.20.2: + resolution: {integrity: sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 dependencies: - '@babel/types': 7.18.2 + '@babel/compat-data': 7.20.1 + '@babel/core': 7.20.2 + '@babel/helper-validator-option': 7.18.6 + browserslist: 4.21.4 + semver: 6.3.0 dev: true /@babel/helper-environment-visitor/7.18.9: @@ -935,14 +911,6 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/helper-function-name/7.17.9: - resolution: {integrity: sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.16.7 - '@babel/types': 7.18.2 - dev: true - /@babel/helper-function-name/7.19.0: resolution: {integrity: sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==} engines: {node: '>=6.9.0'} @@ -951,13 +919,6 @@ packages: '@babel/types': 7.19.4 dev: true - /@babel/helper-hoist-variables/7.16.7: - resolution: {integrity: sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.18.2 - dev: true - /@babel/helper-hoist-variables/7.18.6: resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} engines: {node: '>=6.9.0'} @@ -965,13 +926,6 @@ packages: '@babel/types': 7.19.4 dev: true - /@babel/helper-module-imports/7.16.7: - resolution: {integrity: sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.18.2 - dev: true - /@babel/helper-module-imports/7.18.6: resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} engines: {node: '>=6.9.0'} @@ -979,22 +933,6 @@ packages: '@babel/types': 7.19.4 dev: true - /@babel/helper-module-transforms/7.17.7: - resolution: {integrity: sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-environment-visitor': 7.16.7 - '@babel/helper-module-imports': 7.16.7 - '@babel/helper-simple-access': 7.17.7 - '@babel/helper-split-export-declaration': 7.16.7 - '@babel/helper-validator-identifier': 7.19.1 - '@babel/template': 7.16.7 - '@babel/traverse': 7.17.9 - '@babel/types': 7.18.2 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-module-transforms/7.19.0: resolution: {integrity: sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==} engines: {node: '>=6.9.0'} @@ -1011,6 +949,22 @@ packages: - supports-color dev: true + /@babel/helper-module-transforms/7.20.2: + resolution: {integrity: sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-module-imports': 7.18.6 + '@babel/helper-simple-access': 7.20.2 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-validator-identifier': 7.19.1 + '@babel/template': 7.18.10 + '@babel/traverse': 7.20.1 + '@babel/types': 7.20.2 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/helper-plugin-utils/7.10.4: resolution: {integrity: sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==} dev: true @@ -1025,11 +979,9 @@ packages: engines: {node: '>=6.9.0'} dev: true - /@babel/helper-simple-access/7.17.7: - resolution: {integrity: sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==} + /@babel/helper-plugin-utils/7.20.2: + resolution: {integrity: sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.18.2 dev: true /@babel/helper-simple-access/7.19.4: @@ -1039,11 +991,11 @@ packages: '@babel/types': 7.19.4 dev: true - /@babel/helper-split-export-declaration/7.16.7: - resolution: {integrity: sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==} + /@babel/helper-simple-access/7.20.2: + resolution: {integrity: sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.2 + '@babel/types': 7.20.2 dev: true /@babel/helper-split-export-declaration/7.18.6: @@ -1067,27 +1019,11 @@ packages: resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} engines: {node: '>=6.9.0'} - /@babel/helper-validator-option/7.16.7: - resolution: {integrity: sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==} - engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-validator-option/7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} engines: {node: '>=6.9.0'} dev: true - /@babel/helpers/7.17.9: - resolution: {integrity: sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.16.7 - '@babel/traverse': 7.17.9 - '@babel/types': 7.18.2 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helpers/7.19.4: resolution: {integrity: sha512-G+z3aOx2nfDHwX/kyVii5fJq+bgscg89/dJNWpYeKeBv3v9xX8EIabmx1k6u9LS04H7nROFVRVK+e3k0VHp+sw==} engines: {node: '>=6.9.0'} @@ -1099,6 +1035,17 @@ packages: - supports-color dev: true + /@babel/helpers/7.20.1: + resolution: {integrity: sha512-J77mUVaDTUJFZ5BpP6mMn6OIl3rEWymk2ZxDBQJUG3P+PbmyMcF3bYWvz0ma69Af1oobDqT/iAsvzhB58xhQUg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.18.10 + '@babel/traverse': 7.20.1 + '@babel/types': 7.20.2 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/highlight/7.16.10: resolution: {integrity: sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==} engines: {node: '>=6.9.0'} @@ -1116,14 +1063,6 @@ packages: chalk: 2.4.2 js-tokens: 4.0.0 - /@babel/parser/7.18.3: - resolution: {integrity: sha512-rL50YcEuHbbauAFAysNsJA4/f89fGTOBRNs9P81sniKnKAr4xULe5AecolcsKbi88xu0ByWYDj/S1AJ3FSFuSQ==} - engines: {node: '>=6.0.0'} - hasBin: true - dependencies: - '@babel/types': 7.18.2 - dev: true - /@babel/parser/7.19.4: resolution: {integrity: sha512-qpVT7gtuOLjWeDTKLkJ6sryqLliBaFpAtGeqw5cs5giLldvh+Ch0plqnUMKoVAUS6ZEueQQiZV+p5pxtPitEsA==} engines: {node: '>=6.0.0'} @@ -1132,15 +1071,23 @@ packages: '@babel/types': 7.19.4 dev: true + /@babel/parser/7.20.3: + resolution: {integrity: sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.20.2 + dev: true + /@babel/plugin-proposal-object-rest-spread/7.12.1_@babel+core@7.12.9: resolution: {integrity: sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.9 - '@babel/helper-plugin-utils': 7.17.12 + '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.12.9 - '@babel/plugin-transform-parameters': 7.16.7_@babel+core@7.12.9 + '@babel/plugin-transform-parameters': 7.20.3_@babel+core@7.12.9 dev: true /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.19.3: @@ -1194,7 +1141,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.9 - '@babel/helper-plugin-utils': 7.17.12 + '@babel/helper-plugin-utils': 7.20.2 dev: true /@babel/plugin-syntax-jsx/7.18.6_@babel+core@7.19.3: @@ -1290,14 +1237,14 @@ packages: '@babel/helper-plugin-utils': 7.19.0 dev: true - /@babel/plugin-transform-parameters/7.16.7_@babel+core@7.12.9: - resolution: {integrity: sha512-AT3MufQ7zZEhU2hwOA11axBnExW0Lszu4RL/tAlUJBuNoRak+wehQW8h6KcXOcgjY42fHtDxswuMhMjFEuv/aw==} + /@babel/plugin-transform-parameters/7.20.3_@babel+core@7.12.9: + resolution: {integrity: sha512-oZg/Fpx0YDrj13KsLyO8I/CX3Zdw7z0O9qOd95SqcoIzuqy/WTGWvePeHAnZCN54SfdyjHcb1S30gc8zlzlHcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.9 - '@babel/helper-plugin-utils': 7.17.12 + '@babel/helper-plugin-utils': 7.20.2 dev: true /@babel/runtime-corejs3/7.19.4: @@ -1327,15 +1274,6 @@ packages: dependencies: regenerator-runtime: 0.13.9 - /@babel/template/7.16.7: - resolution: {integrity: sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.18.6 - '@babel/parser': 7.18.3 - '@babel/types': 7.18.2 - dev: true - /@babel/template/7.18.10: resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==} engines: {node: '>=6.9.0'} @@ -1345,24 +1283,6 @@ packages: '@babel/types': 7.19.4 dev: true - /@babel/traverse/7.17.9: - resolution: {integrity: sha512-PQO8sDIJ8SIwipTPiR71kJQCKQYB5NGImbOviK8K+kg5xkNSYXLBupuX9QhatFowrsvo9Hj8WgArg3W7ijNAQw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.18.6 - '@babel/generator': 7.17.9 - '@babel/helper-environment-visitor': 7.16.7 - '@babel/helper-function-name': 7.17.9 - '@babel/helper-hoist-variables': 7.16.7 - '@babel/helper-split-export-declaration': 7.16.7 - '@babel/parser': 7.18.3 - '@babel/types': 7.18.2 - debug: 4.3.4 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/traverse/7.19.4: resolution: {integrity: sha512-w3K1i+V5u2aJUOXBFFC5pveFLmtq1s3qcdDNC2qRI6WPBQIDaKFqXxDEqDO/h1dQ3HjsZoZMyIy6jGLq0xtw+g==} engines: {node: '>=6.9.0'} @@ -1381,6 +1301,24 @@ packages: - supports-color dev: true + /@babel/traverse/7.20.1: + resolution: {integrity: sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.20.4 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.19.0 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.20.3 + '@babel/types': 7.20.2 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types/7.17.0: resolution: {integrity: sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==} engines: {node: '>=6.9.0'} @@ -1389,16 +1327,17 @@ packages: to-fast-properties: 2.0.0 dev: false - /@babel/types/7.18.2: - resolution: {integrity: sha512-0On6B8A4/+mFUto5WERt3EEuG1NznDirvwca1O8UwXQHVY8g3R7OzYgxXdOfMwLO08UrpUD/2+3Bclyq+/C94Q==} + /@babel/types/7.19.4: + resolution: {integrity: sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==} engines: {node: '>=6.9.0'} dependencies: + '@babel/helper-string-parser': 7.19.4 '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 dev: true - /@babel/types/7.19.4: - resolution: {integrity: sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==} + /@babel/types/7.20.2: + resolution: {integrity: sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.19.4 @@ -1677,10 +1616,10 @@ packages: '@types/node': 17.0.23 chalk: 4.1.2 cosmiconfig: 7.0.1 - cosmiconfig-typescript-loader: 2.0.0_bjctuninx3nzqxltyvshqte2ni + cosmiconfig-typescript-loader: 2.0.0_73inix45wpcdjnppmmovzbfudu lodash: 4.17.21 resolve-from: 5.0.0 - typescript: 4.7.4 + typescript: 4.9.4 transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -1860,6 +1799,18 @@ packages: slash: 3.0.0 dev: true + /@jest/console/29.3.1: + resolution: {integrity: sha512-IRE6GD47KwcqA09RIWrabKdHPiKDGgtAL31xDxbi/RjQMsr+lY+ppxmHwY0dUEV3qvvxZzoe5Hl0RXZJOjQNUg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + chalk: 4.1.2 + jest-message-util: 29.3.1 + jest-util: 29.3.1 + slash: 3.0.0 + dev: true + /@jest/core/29.1.2: resolution: {integrity: sha512-sCO2Va1gikvQU2ynDN8V4+6wB7iVrD2CvT0zaRst4rglf56yLly0NQ9nuRRAWFeimRf+tCdFsb1Vk1N9LrrMPA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1944,6 +1895,48 @@ packages: - ts-node dev: true + /@jest/core/29.3.1: + resolution: {integrity: sha512-0ohVjjRex985w5MmO5L3u5GR1O30DexhBSpuwx2P+9ftyqHdJXnk7IUWiP80oHMvt7ubHCJHxV0a0vlKVuZirw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/console': 29.3.1 + '@jest/reporters': 29.3.1 + '@jest/test-result': 29.3.1 + '@jest/transform': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.5.0 + exit: 0.1.2 + graceful-fs: 4.2.10 + jest-changed-files: 29.2.0 + jest-config: 29.3.1_@types+node@17.0.23 + jest-haste-map: 29.3.1 + jest-message-util: 29.3.1 + jest-regex-util: 29.2.0 + jest-resolve: 29.3.1 + jest-resolve-dependencies: 29.3.1 + jest-runner: 29.3.1 + jest-runtime: 29.3.1 + jest-snapshot: 29.3.1 + jest-util: 29.3.1 + jest-validate: 29.3.1 + jest-watcher: 29.3.1 + micromatch: 4.0.5 + pretty-format: 29.3.1 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - supports-color + - ts-node + dev: true + /@jest/environment/27.5.1: resolution: {integrity: sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -1964,6 +1957,16 @@ packages: jest-mock: 29.2.2 dev: true + /@jest/environment/29.3.1: + resolution: {integrity: sha512-pMmvfOPmoa1c1QpfFW0nXYtNLpofqo4BrCIk6f2kW4JFeNlHV2t3vd+3iDLf31e2ot2Mec0uqZfmI+U0K2CFag==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + jest-mock: 29.3.1 + dev: true + /@jest/expect-utils/29.1.2: resolution: {integrity: sha512-4a48bhKfGj/KAH39u0ppzNTABXQ8QPccWAFUFobWBaEMSMp+sB31Z2fK/l47c4a/Mu1po2ffmfAIPxXbVTXdtg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1971,6 +1974,13 @@ packages: jest-get-type: 29.0.0 dev: true + /@jest/expect-utils/29.3.1: + resolution: {integrity: sha512-wlrznINZI5sMjwvUoLVk617ll/UYfGIZNxmbU+Pa7wmkL4vYzhV9R2pwVqUh4NWWuLQWkI8+8mOkxs//prKQ3g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.2.0 + dev: true + /@jest/expect/29.1.2: resolution: {integrity: sha512-FXw/UmaZsyfRyvZw3M6POgSNqwmuOXJuzdNiMWW9LCYo0GRoRDhg+R5iq5higmRTHQY7hx32+j7WHwinRmoILQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1981,6 +1991,16 @@ packages: - supports-color dev: true + /@jest/expect/29.3.1: + resolution: {integrity: sha512-QivM7GlSHSsIAWzgfyP8dgeExPRZ9BIe2LsdPyEhCGkZkoyA+kGsoIzbKAfZCvvRzfZioKwPtCZIt5SaoxYCvg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + expect: 29.3.1 + jest-snapshot: 29.3.1 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/fake-timers/27.5.1: resolution: {integrity: sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2005,6 +2025,18 @@ packages: jest-util: 29.2.1 dev: true + /@jest/fake-timers/29.3.1: + resolution: {integrity: sha512-iHTL/XpnDlFki9Tq0Q1GGuVeQ8BHZGIYsvCO5eN/O/oJaRzofG9Xndd9HuSDBI/0ZS79pg0iwn07OMTQ7ngF2A==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + '@sinonjs/fake-timers': 9.1.2 + '@types/node': 17.0.23 + jest-message-util: 29.3.1 + jest-mock: 29.3.1 + jest-util: 29.3.1 + dev: true + /@jest/globals/29.1.2: resolution: {integrity: sha512-uMgfERpJYoQmykAd0ffyMq8wignN4SvLUG6orJQRe9WAlTRc9cdpCaE/29qurXixYJVZWUqIBXhSk8v5xN1V9g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2017,6 +2049,18 @@ packages: - supports-color dev: true + /@jest/globals/29.3.1: + resolution: {integrity: sha512-cTicd134vOcwO59OPaB6AmdHQMCtWOe+/DitpTZVxWgMJ+YvXL1HNAmPyiGbSHmF/mXVBkvlm8YYtQhyHPnV6Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.3.1 + '@jest/expect': 29.3.1 + '@jest/types': 29.3.1 + jest-mock: 29.3.1 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/reporters/29.1.2: resolution: {integrity: sha512-X4fiwwyxy9mnfpxL0g9DD0KcTmEIqP0jUdnc2cfa9riHy+I6Gwwp5vOZiwyg0vZxfSDxrOlK9S4+340W4d+DAA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2055,6 +2099,43 @@ packages: - supports-color dev: true + /@jest/reporters/29.3.1: + resolution: {integrity: sha512-GhBu3YFuDrcAYW/UESz1JphEAbvUjaY2vShRZRoRY1mxpCMB3yGSJ4j9n0GxVlEOdCf7qjvUfBCrTUUqhVfbRA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.3.1 + '@jest/test-result': 29.3.1 + '@jest/transform': 29.3.1 + '@jest/types': 29.3.1 + '@jridgewell/trace-mapping': 0.3.16 + '@types/node': 17.0.23 + chalk: 4.1.2 + collect-v8-coverage: 1.0.1 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.10 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-instrument: 5.2.1 + istanbul-lib-report: 3.0.0 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 + jest-message-util: 29.3.1 + jest-util: 29.3.1 + jest-worker: 29.3.1 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/schemas/29.0.0: resolution: {integrity: sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2071,6 +2152,15 @@ packages: graceful-fs: 4.2.10 dev: true + /@jest/source-map/29.2.0: + resolution: {integrity: sha512-1NX9/7zzI0nqa6+kgpSdKPK+WU1p+SJk3TloWZf5MzPbxri9UEeXX5bWZAPCzbQcyuAzubcdUHA7hcNznmRqWQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jridgewell/trace-mapping': 0.3.16 + callsites: 3.1.0 + graceful-fs: 4.2.10 + dev: true + /@jest/test-result/29.1.2: resolution: {integrity: sha512-jjYYjjumCJjH9hHCoMhA8PCl1OxNeGgAoZ7yuGYILRJX9NjgzTN0pCT5qAoYR4jfOP8htIByvAlz9vfNSSBoVg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2081,6 +2171,16 @@ packages: collect-v8-coverage: 1.0.1 dev: true + /@jest/test-result/29.3.1: + resolution: {integrity: sha512-qeLa6qc0ddB0kuOZyZIhfN5q0e2htngokyTWsGriedsDhItisW7SDYZ7ceOe57Ii03sL988/03wAcBh3TChMGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/console': 29.3.1 + '@jest/types': 29.3.1 + '@types/istanbul-lib-coverage': 2.0.4 + collect-v8-coverage: 1.0.1 + dev: true + /@jest/test-sequencer/29.1.2: resolution: {integrity: sha512-fU6dsUqqm8sA+cd85BmeF7Gu9DsXVWFdGn9taxM6xN1cKdcP/ivSgXh5QucFRFz1oZxKv3/9DYYbq0ULly3P/Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2091,6 +2191,16 @@ packages: slash: 3.0.0 dev: true + /@jest/test-sequencer/29.3.1: + resolution: {integrity: sha512-IqYvLbieTv20ArgKoAMyhLHNrVHJfzO6ARZAbQRlY4UGWfdDnLlZEF0BvKOMd77uIiIjSZRwq3Jb3Fa3I8+2UA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.3.1 + graceful-fs: 4.2.10 + jest-haste-map: 29.3.1 + slash: 3.0.0 + dev: true + /@jest/transform/29.1.2: resolution: {integrity: sha512-2uaUuVHTitmkx1tHF+eBjb4p7UuzBG7SXIaA/hNIkaMP6K+gXYGxP38ZcrofzqN0HeZ7A90oqsOa97WU7WZkSw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2114,6 +2224,29 @@ packages: - supports-color dev: true + /@jest/transform/29.3.1: + resolution: {integrity: sha512-8wmCFBTVGYqFNLWfcOWoVuMuKYPUBTnTMDkdvFtAYELwDOl9RGwOsvQWGPFxDJ8AWY9xM/8xCXdqmPK3+Q5Lug==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.19.3 + '@jest/types': 29.3.1 + '@jridgewell/trace-mapping': 0.3.16 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.10 + jest-haste-map: 29.3.1 + jest-regex-util: 29.2.0 + jest-util: 29.3.1 + micromatch: 4.0.5 + pirates: 4.0.5 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/types/27.5.1: resolution: {integrity: sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2149,6 +2282,18 @@ packages: chalk: 4.1.2 dev: true + /@jest/types/29.3.1: + resolution: {integrity: sha512-d0S0jmmTpjnhCmNpApgX3jrUZgZ22ivKJRvL2lli5hpCRoNnp1f85r2/wpKfXuYu8E7Jjh1hGfhPyup1NM5AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.0.0 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 17.0.23 + '@types/yargs': 17.0.13 + chalk: 4.1.2 + dev: true + /@jridgewell/gen-mapping/0.1.1: resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==} engines: {node: '>=6.0.0'} @@ -2163,7 +2308,7 @@ packages: dependencies: '@jridgewell/set-array': 1.1.2 '@jridgewell/sourcemap-codec': 1.4.14 - '@jridgewell/trace-mapping': 0.3.15 + '@jridgewell/trace-mapping': 0.3.16 dev: true /@jridgewell/resolve-uri/3.1.0: @@ -2180,22 +2325,22 @@ packages: resolution: {integrity: sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==} dependencies: '@jridgewell/gen-mapping': 0.3.2 - '@jridgewell/trace-mapping': 0.3.15 + '@jridgewell/trace-mapping': 0.3.17 dev: true /@jridgewell/sourcemap-codec/1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} dev: true - /@jridgewell/trace-mapping/0.3.15: - resolution: {integrity: sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==} + /@jridgewell/trace-mapping/0.3.16: + resolution: {integrity: sha512-LCQ+NeThyJ4k1W2d+vIKdxuSt9R3pQSZ4P92m7EakaYuXcVWbHuT5bjNcqLd4Rdgi6xYWYDvBJZJLZSLanjDcA==} dependencies: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /@jridgewell/trace-mapping/0.3.16: - resolution: {integrity: sha512-LCQ+NeThyJ4k1W2d+vIKdxuSt9R3pQSZ4P92m7EakaYuXcVWbHuT5bjNcqLd4Rdgi6xYWYDvBJZJLZSLanjDcA==} + /@jridgewell/trace-mapping/0.3.17: + resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==} dependencies: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 @@ -2273,111 +2418,103 @@ packages: dev: true optional: true - /@logto/browser/1.0.0-beta.13: - resolution: {integrity: sha512-ddAVggFcbS9yfG8Gvn2xknE2NZd6+lGxOQ6UbjIJKsYBAsJG95u1ITYaP7tNSDdxqZPmSBGXp4rfsQB+u0JPJQ==} + /@logto/browser/1.0.0-beta.14: + resolution: {integrity: sha512-yjD1qtRXbX2E5Jgr5F1BK4SRwNhIlbJZK1yZLZNvOltEG76NhfoqvCI8P5PGIiPwvunB2lqPNJFsNOSI3k0Q+w==} dependencies: - '@logto/client': 1.0.0-beta.13 + '@logto/client': 1.0.0-beta.14 '@silverhand/essentials': 1.3.0 - js-base64: 3.7.2 + js-base64: 3.7.3 dev: true - /@logto/client/1.0.0-beta.13: - resolution: {integrity: sha512-ddHILkcBW92p4x/TfUGqT3WXZzX14xgLd6lZZsoCgNZ9QWS7Jw+NsT/knJs96cd2A/jv9RZIGzh1g6+zlox7bw==} + /@logto/client/1.0.0-beta.14: + resolution: {integrity: sha512-quhQJ4rjb1Djhspeq2F5pFxXdgjN5UaWei6nnbUfp12CDhRojKrLJIGl+FDx/HSWuG0b93nwxKnJeJsiX/8E3Q==} dependencies: '@logto/core-kit': 1.0.0-beta.20 - '@logto/js': 1.0.0-beta.13 + '@logto/js': 1.0.0-beta.14 '@silverhand/essentials': 1.3.0 camelcase-keys: 7.0.2 - jose: 4.10.4 + jose: 4.11.1 lodash.get: 4.4.2 lodash.once: 4.1.1 dev: true - /@logto/connector-kit/1.0.0-beta.27_zod@3.19.1: - resolution: {integrity: sha512-wN+m1cQWUZXzci36yUJXNbCTPcj1IZ485+m26rMt/kJ0xosUq9Bt7OtCwL3FNNxkPs+5fF7EbHTWSttW4R90Jw==} + /@logto/connector-kit/1.0.0-beta.28_zod@3.19.1: + resolution: {integrity: sha512-f+Hnn84nC6cnu6jwKLDAYo2+rNypHhWhs1NWYQKWVPUtIoLBKircLKGlvVjzEQnXZM6Cnd47X1Zk15IVFhraWA==} engines: {node: ^16.13.0 || ^18.12.0} peerDependencies: zod: ^3.19.1 dependencies: - '@logto/core-kit': 1.0.0-beta.26_zod@3.19.1 - '@logto/language-kit': 1.0.0-beta.26_zod@3.19.1 + '@logto/core-kit': 1.0.0-beta.28_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 '@silverhand/essentials': 1.3.0 zod: 3.19.1 dev: false - /@logto/core-kit/1.0.0-beta.18: - resolution: {integrity: sha512-VgSBWbPeFHgSOiOoYiE+TQF8byImxfd2xGTjA37RtFHMeUfT8CNTQWESUYuP7JWrKNm1N2ua7DTZZAJAKH9qMg==} - engines: {node: ^16.0.0} - dependencies: - '@logto/language-kit': 1.0.0-beta.20 - color: 4.2.3 - nanoid: 3.3.4 - zod: 3.19.1 - dev: false - /@logto/core-kit/1.0.0-beta.20: resolution: {integrity: sha512-seYvL/aGYRfO4d0FYfKIW/Cu9PnFMRpRM5/oRXwXbcbv+LY1a3TcAX0itrVXeBygIrxiAmWd9DL7CGIWzb48Qg==} engines: {node: ^16.0.0} dependencies: - '@logto/language-kit': 1.0.0-beta.20 + '@logto/language-kit': 1.0.0-beta.29_zod@3.20.0 color: 4.2.3 nanoid: 3.3.4 - zod: 3.19.1 + zod: 3.20.0 + dev: true - /@logto/core-kit/1.0.0-beta.26_zod@3.19.1: - resolution: {integrity: sha512-WsgJ6sIPNO0yH+5V94Vjomi6pP0txky53MkIk7tGV3xGUQSJTBFjEHciTwCl2tHUB6VsPeerjs22jawakFyfFg==} + /@logto/core-kit/1.0.0-beta.28_zod@3.19.1: + resolution: {integrity: sha512-tbT34SupNvk7mriMhBq32KknCEe+qm7LrZBc2E7JDD3FBJmMxyHQCJgdPBlnR+0sXB0uG32NmYwLqG4p9slAPw==} engines: {node: ^16.13.0 || ^18.12.0} peerDependencies: zod: ^3.19.1 dependencies: - '@logto/language-kit': 1.0.0-beta.26_zod@3.19.1 + '@logto/language-kit': 1.0.0-beta.28_zod@3.19.1 color: 4.2.3 nanoid: 3.3.4 zod: 3.19.1 - dev: false - /@logto/js/1.0.0-beta.13: - resolution: {integrity: sha512-a3dhoJre/VOXgGxFNon/xY5E4fVs0CiFW5Ci0gt2W7v2zD/1VmzIvcJnECTLocxyz9W2UTkfYzm7q/iXF48WXw==} + /@logto/js/1.0.0-beta.14: + resolution: {integrity: sha512-fMmZHfqkgpArcQxN8+Aj3hMJ+Gpyg7fg+iiyKKw4IOGzG0oe1D7R18EQ/jUTpw87mL4wxKpOOwsDpMgixfgQxQ==} dependencies: '@logto/core-kit': 1.0.0-beta.20 '@silverhand/essentials': 1.3.0 camelcase-keys: 7.0.2 - jose: 4.10.4 + jose: 4.11.1 lodash.get: 4.4.2 dev: true - /@logto/language-kit/1.0.0-beta.20: - resolution: {integrity: sha512-nBqWQo2xGAlVcD9O/txpCzRyy7eKXNXBAHm8J1y/u5Fp3BMObMmJv+v4Zk+UhckdFpsnFJF0wYIX45ta2+IipA==} - engines: {node: ^16.0.0} - dependencies: - zod: 3.19.1 - - /@logto/language-kit/1.0.0-beta.26_zod@3.19.1: - resolution: {integrity: sha512-z8YufwLQoVfx3NcNiz2ORlmOIRFy8K+OY0e8bAhnT+N3x8MSPPlcFmK2EidEpl3DqL27QLJVHQaOsEnDHCad8g==} + /@logto/language-kit/1.0.0-beta.28_zod@3.19.1: + resolution: {integrity: sha512-levNkJ0uiTufnn0r8dLZLdPKgRFiV5mSQ0H04AhpP9dHlm7+1PMRspoi8NjcQqUgemhmcwpP8oCtn4rgW8vpJA==} engines: {node: ^16.13.0 || ^18.12.0} peerDependencies: zod: ^3.19.1 dependencies: zod: 3.19.1 - dev: false - /@logto/node/1.0.0-beta.13: - resolution: {integrity: sha512-e+Vspi17izZdt6PU2+uFXrD2XydZ8ohthtOMJqhrzpC1DiOSlmb47UXo3K+IS93eULiuPNo5mA04hRn3kt6rcQ==} + /@logto/language-kit/1.0.0-beta.29_zod@3.20.0: + resolution: {integrity: sha512-+YeAkawjEq0vwwnqq8RDrDKePWE6x1q2WdpLXtj0H6SQ3GB9pXcYLecjnANHOHB4Zp9Jnxd4eBGogNRWvspikg==} + engines: {node: ^16.13.0 || ^18.12.0} + peerDependencies: + zod: ^3.19.1 dependencies: - '@logto/client': 1.0.0-beta.13 + zod: 3.20.0 + dev: true + + /@logto/node/1.0.0-beta.14: + resolution: {integrity: sha512-+0S6lBBcG3pOmjEMRQnD+6X0MJ3V3E/4In59ckl/uVr/UgIufvOKWJwWCfsVKyguaO3QweJn19x7YkF8FyO31g==} + dependencies: + '@logto/client': 1.0.0-beta.14 '@silverhand/essentials': 1.3.0 - js-base64: 3.7.2 + js-base64: 3.7.3 node-fetch: 2.6.7 transitivePeerDependencies: - encoding dev: true - /@logto/react/1.0.0-beta.13_react@18.2.0: - resolution: {integrity: sha512-jG7rXm5aW/aJ+RN9Rw8tEo4c/12LzO99qt8eZ39xFWfMB+g06/rmxPZGMMkkqaNexhE2KLr4cASYeOafTR8wwQ==} + /@logto/react/1.0.0-beta.14_react@18.2.0: + resolution: {integrity: sha512-lHuwpHzJkIbHj/VvhzxmL7hWkyDYA8rInv0sm0M21br43OotgP7fMc62Wj78ty+QIj+or5UGwCcULBa8HySQcQ==} peerDependencies: react: '>=16.8.0 || ^18.0.0' dependencies: - '@logto/browser': 1.0.0-beta.13 + '@logto/browser': 1.0.0-beta.14 '@silverhand/essentials': 1.3.0 react: 18.2.0 dev: true @@ -2449,6 +2586,54 @@ packages: json5: 2.2.1 dev: true + /@msgpackr-extract/msgpackr-extract-darwin-arm64/2.2.0: + resolution: {integrity: sha512-Z9LFPzfoJi4mflGWV+rv7o7ZbMU5oAU9VmzCgL240KnqDW65Y2HFCT3MW06/ITJSnbVLacmcEJA8phywK7JinQ==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@msgpackr-extract/msgpackr-extract-darwin-x64/2.2.0: + resolution: {integrity: sha512-vq0tT8sjZsy4JdSqmadWVw6f66UXqUCabLmUVHZwUFzMgtgoIIQjT4VVRHKvlof3P/dMCkbMJ5hB1oJ9OWHaaw==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@msgpackr-extract/msgpackr-extract-linux-arm/2.2.0: + resolution: {integrity: sha512-SaJ3Qq4lX9Syd2xEo9u3qPxi/OB+5JO/ngJKK97XDpa1C587H9EWYO6KD8995DAjSinWvdHKRrCOXVUC5fvGOg==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@msgpackr-extract/msgpackr-extract-linux-arm64/2.2.0: + resolution: {integrity: sha512-hlxxLdRmPyq16QCutUtP8Tm6RDWcyaLsRssaHROatgnkOxdleMTgetf9JsdncL8vLh7FVy/RN9i3XR5dnb9cRA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@msgpackr-extract/msgpackr-extract-linux-x64/2.2.0: + resolution: {integrity: sha512-94y5PJrSOqUNcFKmOl7z319FelCLAE0rz/jPCWS+UtdMZvpa4jrQd+cJPQCLp2Fes1yAW/YUQj/Di6YVT3c3Iw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@msgpackr-extract/msgpackr-extract-win32-x64/2.2.0: + resolution: {integrity: sha512-XrC0JzsqQSvOyM3t04FMLO6z5gCuhPE6k4FXuLK5xf52ZbdvcFe1yBmo7meCew9B8G2f0T9iu9t3kfTYRYROgA==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2470,84 +2655,85 @@ packages: fastq: 1.13.0 dev: true - /@parcel/bundler-default/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-PU5MtWWhc+dYI9x8mguYnm9yiG6TkI7niRpxgJgtqAyGHuEyNXVBQQ0X+qyOF4D9LdankBf8uNN18g31IET2Zg==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/bundler-default/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-OvDDhxX4LwfGe7lYVMbJMzqNcDk8ydOqNw0Hra9WPgl0m5gju/eVIbDvot3JXp5F96FmV36uCxdODJhKTNoAzQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/graph': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/cache/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-JlXNoZXcWzLKdDlfeF3dIj5Vtel5T9vtdBN72PJ+cjC4qNHk4Uwvc5sfOBELuibGN0bVu2bwY9nUgSwCiB1iIA==} + /@parcel/cache/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-k945hrafMDR2wyCKyZYgwypeLLuZWce6FzhgunI4taBUeVnNCcpFAWzbfOVQ39SqZTGDqG3MNT+VuehssHXxyg==} engines: {node: '>= 12.0.0'} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/core': 2.7.0 - '@parcel/fs': 2.7.0_@parcel+core@2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/core': 2.8.0 + '@parcel/fs': 2.8.0_@parcel+core@2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/utils': 2.8.0 lmdb: 2.5.2 dev: true - /@parcel/codeframe/2.7.0: - resolution: {integrity: sha512-UTKx0jejJmmO1dwTHSJuRgrO8N6PMlkxRT6sew8N6NC3Bgv6pu0EbO+RtlWt/jCvzcdLOPdIoTzj4MMZvgcMYg==} + /@parcel/codeframe/2.8.0: + resolution: {integrity: sha512-821d+KVcpEvJNMj9WMC39xXZK6zvRS/HUjQag2f3DkcRcZwk1uXJZdW6p1EB7C3e4e/0KSK3NTSVGEvbOSR+9w==} engines: {node: '>= 12.0.0'} dependencies: chalk: 4.1.2 dev: true - /@parcel/compressor-raw/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-SCXwnOOQT6EmpusBsYWNQ/RFri+2JnKuE0gMSf2dROl2xbererX45FYzeDplWALCKAdjMNDpFwU+FyMYoVZSCQ==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/compressor-raw/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-tM49t0gDQnwJbrDCeoCn9LRc8inZ/TSPQTttJTfcmFHHFqEllI0ZDVG0AiQw5NOMQbBLYiKun1adXn8pkcPLEA==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/config-default/2.7.0_6sm72dhu5rdyvbpt4eu53qwhom: - resolution: {integrity: sha512-ZzsLr97AYrz8c9k6qn3DlqPzifi3vbP7q3ynUrAFxmt0L4+K0H9N508ZkORYmCgaFjLIQ8Y3eWpwCJ0AewPNIg==} + /@parcel/config-default/2.8.0_c2xncdofe2kmjujtwoy3fucw4m: + resolution: {integrity: sha512-j9g50QNSLjuNpY0TP01EgGJPxWNes9d+e8+N07Z5Wv0u+UUnJ2uIOpo7PVn7ullOGhm1f9lP4KsJenu5gWb+cg==} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/bundler-default': 2.7.0_@parcel+core@2.7.0 - '@parcel/compressor-raw': 2.7.0_@parcel+core@2.7.0 - '@parcel/core': 2.7.0 - '@parcel/namer-default': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-css': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-htmlnano': 2.7.0_6sm72dhu5rdyvbpt4eu53qwhom - '@parcel/optimizer-image': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-svgo': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-terser': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-css': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-html': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-js': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-raw': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-svg': 2.7.0_@parcel+core@2.7.0 - '@parcel/reporter-dev-server': 2.7.0_@parcel+core@2.7.0 - '@parcel/resolver-default': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-browser-hmr': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-js': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-react-refresh': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-service-worker': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-babel': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-css': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-html': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-image': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-js': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-json': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-postcss': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-posthtml': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-raw': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-react-refresh-wrap': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-svg': 2.7.0_@parcel+core@2.7.0 + '@parcel/bundler-default': 2.8.0_@parcel+core@2.8.0 + '@parcel/compressor-raw': 2.8.0_@parcel+core@2.8.0 + '@parcel/core': 2.8.0 + '@parcel/namer-default': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-css': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-htmlnano': 2.8.0_c2xncdofe2kmjujtwoy3fucw4m + '@parcel/optimizer-image': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-svgo': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-terser': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-css': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-html': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-js': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-raw': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-svg': 2.8.0_@parcel+core@2.8.0 + '@parcel/reporter-dev-server': 2.8.0_@parcel+core@2.8.0 + '@parcel/resolver-default': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-browser-hmr': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-js': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-react-refresh': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-service-worker': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-babel': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-css': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-html': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-image': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-js': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-json': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-postcss': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-posthtml': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-raw': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-react-refresh-wrap': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-svg': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - cssnano - postcss @@ -2558,42 +2744,42 @@ packages: - uncss dev: true - /@parcel/config-default/2.7.0_bwsugnjfjtzrnvkdck67xbzp2m: - resolution: {integrity: sha512-ZzsLr97AYrz8c9k6qn3DlqPzifi3vbP7q3ynUrAFxmt0L4+K0H9N508ZkORYmCgaFjLIQ8Y3eWpwCJ0AewPNIg==} + /@parcel/config-default/2.8.0_qtrkvsfkn7ppxb6dtfnkut4prm: + resolution: {integrity: sha512-j9g50QNSLjuNpY0TP01EgGJPxWNes9d+e8+N07Z5Wv0u+UUnJ2uIOpo7PVn7ullOGhm1f9lP4KsJenu5gWb+cg==} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/bundler-default': 2.7.0_@parcel+core@2.7.0 - '@parcel/compressor-raw': 2.7.0_@parcel+core@2.7.0 - '@parcel/core': 2.7.0 - '@parcel/namer-default': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-css': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-htmlnano': 2.7.0_bwsugnjfjtzrnvkdck67xbzp2m - '@parcel/optimizer-image': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-svgo': 2.7.0_@parcel+core@2.7.0 - '@parcel/optimizer-terser': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-css': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-html': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-js': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-raw': 2.7.0_@parcel+core@2.7.0 - '@parcel/packager-svg': 2.7.0_@parcel+core@2.7.0 - '@parcel/reporter-dev-server': 2.7.0_@parcel+core@2.7.0 - '@parcel/resolver-default': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-browser-hmr': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-js': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-react-refresh': 2.7.0_@parcel+core@2.7.0 - '@parcel/runtime-service-worker': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-babel': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-css': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-html': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-image': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-js': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-json': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-postcss': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-posthtml': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-raw': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-react-refresh-wrap': 2.7.0_@parcel+core@2.7.0 - '@parcel/transformer-svg': 2.7.0_@parcel+core@2.7.0 + '@parcel/bundler-default': 2.8.0_@parcel+core@2.8.0 + '@parcel/compressor-raw': 2.8.0_@parcel+core@2.8.0 + '@parcel/core': 2.8.0 + '@parcel/namer-default': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-css': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-htmlnano': 2.8.0_qtrkvsfkn7ppxb6dtfnkut4prm + '@parcel/optimizer-image': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-svgo': 2.8.0_@parcel+core@2.8.0 + '@parcel/optimizer-terser': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-css': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-html': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-js': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-raw': 2.8.0_@parcel+core@2.8.0 + '@parcel/packager-svg': 2.8.0_@parcel+core@2.8.0 + '@parcel/reporter-dev-server': 2.8.0_@parcel+core@2.8.0 + '@parcel/resolver-default': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-browser-hmr': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-js': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-react-refresh': 2.8.0_@parcel+core@2.8.0 + '@parcel/runtime-service-worker': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-babel': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-css': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-html': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-image': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-js': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-json': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-postcss': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-posthtml': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-raw': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-react-refresh-wrap': 2.8.0_@parcel+core@2.8.0 + '@parcel/transformer-svg': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - cssnano - postcss @@ -2604,231 +2790,143 @@ packages: - uncss dev: true - /@parcel/core/2.7.0: - resolution: {integrity: sha512-7yKZUdh314Q/kU/9+27ZYTfcnXS6VYHuG+iiUlIohnvUUybxLqVJhdMU9Q+z2QcPka1IdJWz4K4Xx0y6/4goyg==} + /@parcel/core/2.8.0: + resolution: {integrity: sha512-udzbe3jjbpfKlRE9pdlROAa+lvAjS1L/AzN6r2j1y/Fsn7ze/NfvnCFw6o2YNIrXg002aQ7M1St/x1fdGfmVKA==} engines: {node: '>= 12.0.0'} dependencies: '@mischnic/json-sourcemap': 0.1.0 - '@parcel/cache': 2.7.0_@parcel+core@2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/events': 2.7.0 - '@parcel/fs': 2.7.0_@parcel+core@2.7.0 - '@parcel/graph': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/package-manager': 2.7.0_@parcel+core@2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 - abortcontroller-polyfill: 1.7.3 + '@parcel/cache': 2.8.0_@parcel+core@2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/events': 2.8.0 + '@parcel/fs': 2.8.0_@parcel+core@2.8.0 + '@parcel/graph': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/package-manager': 2.8.0_@parcel+core@2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 + abortcontroller-polyfill: 1.7.5 base-x: 3.0.9 - browserslist: 4.20.3 + browserslist: 4.21.4 clone: 2.1.2 dotenv: 7.0.0 dotenv-expand: 5.1.0 json5: 2.2.1 - msgpackr: 1.5.4 + msgpackr: 1.8.0 nullthrows: 1.1.1 semver: 5.7.1 dev: true - /@parcel/css-darwin-arm64/1.12.2: - resolution: {integrity: sha512-6VvsoYSltBiUh/uyfPzQ+I3DiTFN7tmRv6zm1LH98J7GGCDDhbYEtbQjjCs15ex6fVn1ORZK0JO+mMlsg1JwTA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-darwin-x64/1.12.2: - resolution: {integrity: sha512-3J0/LrDvt5vevOisnrE0q5mEcuiAY+K7OZwIv84SAnrbjlL5sshmIaaNzL869kb4thza+RClEj0mS5XTm1IUEw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-linux-arm-gnueabihf/1.12.2: - resolution: {integrity: sha512-OsX7I3dhBvnxEbAH++08RFe7yhjRp33ulzrCvJTMOP9YkxEEJ8qId3sNzJBHIVQzHyTlPTnBRHbSDhU3TFe/eQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-linux-arm64-gnu/1.12.2: - resolution: {integrity: sha512-R1Kqw+1Rsru9Q4+qvUEC6B8P21bpqhuF9rv8GmBmmnF1i2hMZ1JiY+uh/ej8IaRV0O3fAHeQGIyGBWx6qWDpcw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-linux-arm64-musl/1.12.2: - resolution: {integrity: sha512-nwixgM4SEgPUQata9aAiJW0A5Q9ms+xim1tXT1i+91kOei4Fu2Wr2OuofMk+mlhbgmGKCTcu4gzMPReGxUhuRA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-linux-x64-gnu/1.12.2: - resolution: {integrity: sha512-cJYVMHnQSGhDwQByyvjFZppjMBNlgxXl/R4cX5DwrQE0QZmK/42BYnMp92rvoprEG6LRyRoiGtCjyfYTPWajog==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-linux-x64-musl/1.12.2: - resolution: {integrity: sha512-u9zdO/d831/74Tf+TdPUfaIuB9v6FD4Xz8UdWUDOXgQqaOlnJ9fAsAM39EkoWlMxPPljY3f4ay6irSe1a4XgSA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@parcel/css-win32-x64-msvc/1.12.2: - resolution: {integrity: sha512-kCAKr3vKqvPUv9oXBG3pGZQz5il3sEk35dpmTXFa/7eDNKR5XyLpiJs8JwWJTFfuUqroymDSXA1bCcjvNEYcAg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@parcel/css/1.12.2: - resolution: {integrity: sha512-Sa0PvZu5u877CupQA8IjEATqjJFynBfA7LxbcyutFe2LDCRSqB5Bm08jKFScyaz56qjZNIxZxXk2SApNkOvoAA==} - engines: {node: '>= 12.0.0'} - dependencies: - detect-libc: 1.0.3 - optionalDependencies: - '@parcel/css-darwin-arm64': 1.12.2 - '@parcel/css-darwin-x64': 1.12.2 - '@parcel/css-linux-arm-gnueabihf': 1.12.2 - '@parcel/css-linux-arm64-gnu': 1.12.2 - '@parcel/css-linux-arm64-musl': 1.12.2 - '@parcel/css-linux-x64-gnu': 1.12.2 - '@parcel/css-linux-x64-musl': 1.12.2 - '@parcel/css-win32-x64-msvc': 1.12.2 - dev: true - - /@parcel/diagnostic/2.7.0: - resolution: {integrity: sha512-pdq/cTwVoL0n8yuDCRXFRSQHVWdmmIXPt3R3iT4KtYDYvOrMT2dLPT79IMqQkhYPANW8GuL15n/WxRngfRdkug==} + /@parcel/diagnostic/2.8.0: + resolution: {integrity: sha512-ERnk0zDvm0jQUSj1M+2PLiwVC6nWrtuFEuye6VGuxRDcp9NHbz6gwApeEYxFkPsb3TQPhNjnXXm5nmAw1bpWWw==} engines: {node: '>= 12.0.0'} dependencies: '@mischnic/json-sourcemap': 0.1.0 nullthrows: 1.1.1 dev: true - /@parcel/events/2.7.0: - resolution: {integrity: sha512-kQDwMKgZ1U4M/G17qeDYF6bW5kybluN6ajYPc7mZcrWg+trEI/oXi81GMFaMX0BSUhwhbiN5+/Vb2wiG/Sn6ig==} + /@parcel/events/2.8.0: + resolution: {integrity: sha512-xqSZYY3oONM4IZm9+vhyFqX+KFIl145veIczUikwGJlcJZQfAAw736syPx6ecpB+m1EVg3AlvJWy7Lmel4Ak+Q==} engines: {node: '>= 12.0.0'} dev: true - /@parcel/fs-search/2.7.0: - resolution: {integrity: sha512-K1Hv25bnRpwQVA15RvcRuB8ZhfclnCHA8N8L6w7Ul1ncSJDxCIkIAc5hAubYNNYW3kWjCC2SOaEgFKnbvMllEQ==} + /@parcel/fs-search/2.8.0: + resolution: {integrity: sha512-yo7/Y8DCFlhOlIBb5SsRDTkM+7g0DY9sK57iw3hn2z1tGoIiIRptrieImFYSizs7HfDwDY/PMLfORmUdoReDzQ==} engines: {node: '>= 12.0.0'} dependencies: detect-libc: 1.0.3 dev: true - /@parcel/fs/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-PU5fo4Hh8y03LZgemgVREttc0wyHQUNmsJCybxTB7EjJie2CqJRumo+DFppArlvdchLwJdc9em03yQV/GNWrEg==} + /@parcel/fs/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-v3DbJlpl8v2/VRlZPw7cy+0myi0YfLblGZcwDvqIsWS35qyxD2rmtYV8u1BusonbgmJeaKiopSECmJkumt0jCw==} engines: {node: '>= 12.0.0'} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/core': 2.7.0 - '@parcel/fs-search': 2.7.0 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 - '@parcel/watcher': 2.0.5 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 + '@parcel/core': 2.8.0 + '@parcel/fs-search': 2.8.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 + '@parcel/watcher': 2.0.7 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 dev: true - /@parcel/graph/2.7.0: - resolution: {integrity: sha512-Q6E94GS6q45PtsZh+m+gvFRp/N1Qopxhu2sxjcWsGs5iBd6IWn2oYLWOH5iVzEjWuYpW2HkB08lH6J50O63uOA==} + /@parcel/graph/2.8.0: + resolution: {integrity: sha512-JvAyvBpGmhZ30bi+hStQr52eu+InfJBoiN9Z/32byIWhXEl02EAOwfsPqAe+FGCsdgXnnCGg5F9ZCqwzZ9dwbw==} engines: {node: '>= 12.0.0'} dependencies: - '@parcel/utils': 2.7.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 dev: true - /@parcel/hash/2.7.0: - resolution: {integrity: sha512-k6bSKnIlPJMPU3yjQzfgfvF9zuJZGOAlJgzpL4BbWvdbE8BTdjzLcFn0Ujrtud94EgIkiXd22sC2HpCUWoHGdA==} + /@parcel/hash/2.8.0: + resolution: {integrity: sha512-KV1+96t7Nukth5K7ldUXjVr8ZTH9Dohl49K0Tc+5Qkysif0OxwcDtpVDmcnrUnWmqdBX0AdoLY0Q2Nnly89n/w==} engines: {node: '>= 12.0.0'} dependencies: detect-libc: 1.0.3 xxhash-wasm: 0.4.2 dev: true - /@parcel/logger/2.7.0: - resolution: {integrity: sha512-qjMY/bYo38+o+OiIrTRldU9CwL1E7J72t+xkTP8QIcUxLWz5LYR0YbynZUVulmBSfqsykjjxCy4a+8siVr+lPw==} + /@parcel/logger/2.8.0: + resolution: {integrity: sha512-W+7rKsLxLUX6xRmP8PhGWcG48PqrzTPeMWpgSds5nXxAHEFh4cYbkwPKGoTU65a9xUDVyqNreHNIKyizgwAZHQ==} engines: {node: '>= 12.0.0'} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/events': 2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/events': 2.8.0 dev: true - /@parcel/markdown-ansi/2.7.0: - resolution: {integrity: sha512-ipOX0D6FVZFEXeb/z8MnTMq2RQEIuaILY90olVIuHEFLHHfOPEn+RK3u13HA1ChF5/9E3cMD79tu6x9JL9Kqag==} + /@parcel/markdown-ansi/2.8.0: + resolution: {integrity: sha512-xItzXmc3btFhJXsIbE946iaqE6STd2xe5H0zSIaZVXEeucCtMzcd4hxRELquxPstlrAOrrp/lrRpbAlMhso9iA==} engines: {node: '>= 12.0.0'} dependencies: chalk: 4.1.2 dev: true - /@parcel/namer-default/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-lIKMdsmi//7fepecNDYmJYzBlL91HifPsX03lJCdu1dC6q5fBs+gG0XjKKG7yPnSCw1qH/4m7drzt9+dRZYAHQ==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/namer-default/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-cVCx2kJA/Bv7O9pVad1UOibaybR/B+QdWV8Ols8HH4lC2gyjLBXEIR0uuPSEbkGwMEcofG6zA3MwsoPa6r5lBg==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/node-resolver-core/2.7.0: - resolution: {integrity: sha512-5UJQHalqMxdhJIs2hhqQzFfQpF7+NAowsRq064lYtiRvcD8wMr3OOQ9wd1iazGpFSl4JKdT7BwDU9/miDJmanQ==} + /@parcel/node-resolver-core/2.8.0: + resolution: {integrity: sha512-cECSh08NSRt1csmmMeKxlnO6ZhXRTuRijkHKFa4iG5hPL+3Cu04YGhuK/QWlP5vNCPVrH3ISlhzlPU5fAi/nEg==} engines: {node: '>= 12.0.0'} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 semver: 5.7.1 dev: true - /@parcel/optimizer-css/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-IfnOMACqhcAclKyOW9X9JpsknB6OShk9OVvb8EvbDTKHJhQHNNmzE88OkSI/pS3ZVZP9Zj+nWcVHguV+kvDeiQ==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/optimizer-css/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-T5r3gZVm1xFw6l//iLkzLDUvFzNTUvL5kAtyU5gS5yH/dg7eCS09Km/c2anViQnmXwFUt7zIlBovj1doxAVNSw==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/css': 1.12.2 - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 - browserslist: 4.20.3 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 + browserslist: 4.21.4 + lightningcss: 1.16.1 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/optimizer-htmlnano/2.7.0_6sm72dhu5rdyvbpt4eu53qwhom: - resolution: {integrity: sha512-5QrGdWS5Hi4VXE3nQNrGqugmSXt68YIsWwKRAdarOxzyULSJS3gbCiQOXqIPRJobfZjnSIcdtkyxSiCUe1inIA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/optimizer-htmlnano/2.8.0_c2xncdofe2kmjujtwoy3fucw4m: + resolution: {integrity: sha512-NxEKTRvue/WAU+XbQGfNIU6c7chDekdkwwv9YnCxHEOhnBu4Ok+2tdmCtPuA+4UUNszGxXlaHMnqSrjmqX2S6Q==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - htmlnano: 2.0.0_postcss@8.4.14+svgo@2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + htmlnano: 2.0.3_postcss@8.4.6+svgo@2.8.0 nullthrows: 1.1.1 posthtml: 0.16.6 svgo: 2.8.0 @@ -2843,12 +2941,12 @@ packages: - uncss dev: true - /@parcel/optimizer-htmlnano/2.7.0_bwsugnjfjtzrnvkdck67xbzp2m: - resolution: {integrity: sha512-5QrGdWS5Hi4VXE3nQNrGqugmSXt68YIsWwKRAdarOxzyULSJS3gbCiQOXqIPRJobfZjnSIcdtkyxSiCUe1inIA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/optimizer-htmlnano/2.8.0_qtrkvsfkn7ppxb6dtfnkut4prm: + resolution: {integrity: sha512-NxEKTRvue/WAU+XbQGfNIU6c7chDekdkwwv9YnCxHEOhnBu4Ok+2tdmCtPuA+4UUNszGxXlaHMnqSrjmqX2S6Q==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - htmlnano: 2.0.0_postcss@8.4.6+svgo@2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + htmlnano: 2.0.3_postcss@8.4.14+svgo@2.8.0 nullthrows: 1.1.1 posthtml: 0.16.6 svgo: 2.8.0 @@ -2863,224 +2961,224 @@ packages: - uncss dev: true - /@parcel/optimizer-image/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-EnaXz5UjR67FUu0BEcqZTT9LsbB/iFAkkghCotbnbOuC5QQsloq6tw54TKU3y+R3qsjgUoMtGxPcGfVoXxZXYw==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/optimizer-image/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-66eSoCCGZVRiY6U4OqqYrhQcBcHI9cOkIEbxadZYOF4cJhsskjUDJR0jLb4j2PE6QxUNYlyj5OglQqRLwhz7vA==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 detect-libc: 1.0.3 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/optimizer-svgo/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-IO1JV4NpfP3V7FrhsqCcV8pDQIHraFi1/ZvEJyssITxjH49Im/txKlwMiQuZZryAPn8Xb8g395Muawuk6AK6sg==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/optimizer-svgo/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-qQzM32CzJJuniFaTZDspVn/Vtz/PJ/f89+FckLpWZJVWNihgwTHC1/F0YTDH8g6czNw5ZijwQ3xBVuJQYyIXsQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 svgo: 2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/optimizer-terser/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-07VZjIO8xsl2/WmS/qHI8lI/cpu47iS9eRpqwfZEEsdk1cfz50jhWkmFudHBxiHGMfcZ//1+DdaPg9RDBWZtZA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/optimizer-terser/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-slS6GWQ3u418WtJmlqlA5Njljcq4OaEdDDR2ifEwltG8POv+hsvD5AAoM2XB0GJwY97TQtdMbBu2DuDF3yM/1Q==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 - terser: 5.15.0 + terser: 5.15.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/package-manager/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-wmfSX1mRrTi8MeA4KrnPk/x7zGUsILCQmTo6lA4gygzAxDbM1pGuyFN8/Kt0y0SFO2lbljARtD/4an5qdotH+Q==} + /@parcel/package-manager/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-n4FgerAX1lTKKTgxmiocnos47Y+b0L60iwU6Q4cC2n4KQNRuNyfhxFXwWcqHstR9wa72JgPaDgo4k0l3Bk8FZw==} engines: {node: '>= 12.0.0'} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/core': 2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/fs': 2.7.0_@parcel+core@2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 + '@parcel/core': 2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/fs': 2.8.0_@parcel+core@2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 semver: 5.7.1 dev: true - /@parcel/packager-css/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-44nzZwu+ssGuiFmYM6cf/Y4iChiUZ4DUzzpegnGlhXtKJKe4NHntxThJynuRZWKN2AAf48avApDpimg2jW0KDw==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/packager-css/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-tv/Bto0P6fXjqQ9uCZ8/6b/+38Zr/N2MC7/Nbflzww/lp0k2+kkE9MVJJDr5kST/SzTBRrhbDo+yTbtdZikJYg==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/packager-html/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-Zgqd7sdcY/UnR370GR0q2ilmEohUDXsO8A1F28QCJzIsR1iCB6KRUT74+pawfQ1IhXZLaaFLLYe0UWcfm0JeXg==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/packager-html/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-4x09v/bt767rxwGTuEw82CjheoOtIKNu4sx1gqwQOz9QowKPniXOIaD+0XmLiARdzRErucf0sL19QHfNcPAhUw==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 posthtml: 0.16.6 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/packager-js/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-wTRdM81PgRVDzWGXdWmqLwguWnTYWzhEDdjXpW2n8uMOu/CjHhMtogk65aaYk3GOnq6OBL/NsrmBiV/zKPj1vA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/packager-js/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-Tn2EtWM1TEdj4t5pt0QjBDzqrXrfRTL3WsdMipZwDSuX04KS0jedJINHjh46HOMwyfJxLbUg3xkGX7F5mYQj5g==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 - globals: 13.16.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 + globals: 13.18.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/packager-raw/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-jg2Zp8dI5VpIQlaeahXDCfrPN9m/DKht1NkR9P2CylMAwqCcc1Xc1RRiF0wfwcPZpPMpq1265n+4qnB7rjGBlA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/packager-raw/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-s3VniER3X2oNTlfytBGIQF+UZFVNLFWuVu1IkZ8Wg6uYQffrExDlbNDcmFCDcfvcejL3Ch5igP+L6N00f6+wAQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/packager-svg/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-EmJg3HpD6/xxKBjir/CdCKJZwI24iVfBuxRS9LUp3xHAIebOzVh1z6IN+i2Di5+NyRwfOFaLliL4uMa1zwbyCA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/packager-svg/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-+BSpdPiNjlAne28nOjG2AyiOejAehe/+X9MxL2FIpPP7UBLNc2ekaM0mDTR5iY45YtZa57oyErBT/U6wZ1TCjw==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 posthtml: 0.16.6 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/plugin/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-qqgx+nnMn6/0lRc4lKbLGmhNtBiT93S2gFNB4Eb4Pfz/SxVYoW+fmml+KdfOSiZffWOAH5L6NwhyD7N8aSikzw==} + /@parcel/plugin/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-Tsf+7nDg7KauvTVY6rGc7CmgJruKSwJ54KJ9s5nYFFP9nfwmyqbayCi9xOxicWU9zIHfuF5Etwf17lcA0oAvzw==} engines: {node: '>= 12.0.0'} dependencies: - '@parcel/types': 2.7.0_@parcel+core@2.7.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/reporter-cli/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-80gEODg8cnAmnxGVuaSVDo8JJ54P9AA2bHwSs1cIkHWlJ3BjDQb83H31bBHncJ5Kn5kQ/j+7WjlqHpTCiOR9PA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/reporter-cli/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-ea4/Lp+2jDbzb/tfTgUKzYU51FK8wcewDoYNr06uL+wvx/vzYIDG0jHfzaOTasREnm7ECDr1Zu2Iknrgk1STqQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 chalk: 4.1.2 term-size: 2.2.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/reporter-dev-server/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-ySuou5addK8fGue8aXzo536BaEjMujDrEc1xkp4TasInXHVcA98b+SYX5NAZTGob5CxKvZQ5ylhg77zW30B+iA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/reporter-dev-server/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-wg6hUrQ8vUmvlP2fg8YEzYndmq7hWZ21ZgBv4So1Z65I+Qav85Uox7bjGLCSJwEAjdjFKfhV9RGULGzqh8vcAQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/resolver-default/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-v8TvWsbLK7/q7n4gv6OrYNbW18xUx4zKbVMGZb1u4yMhzEH4HFr1D9OeoTq3jk+ximAigds8B6triQbL5exF7A==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/resolver-default/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-kO5W+O3Ql6NXNFS6lvfSSt1R+PxO1atNLYxZdVSM6+QQxRMiztfqzZs//RM+oUp+af6muDSUPlNs+RORX0fing==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/node-resolver-core': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/node-resolver-core': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/runtime-browser-hmr/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-PLbMLdclQeYsi2LkilZVGFV1n3y55G1jaBvby4ekedUZjMw3SWdMY2tDxgSDdFWfLCnYHJXdGUQSzGGi1kPzjA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/runtime-browser-hmr/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-zV5wGGvm1cDwWAzkwPUaKh6inWYKxq67YWY4G396PXLMxddM9SQC1c7iFM60OPnD4A+BMOLOy7N6//20h15Dlg==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/runtime-js/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-9/YUZTBNrSN2H6rbz/o1EOM0O7I3ZR/x9IDzxjJBD6Mi+0uCgCD02aedare/SNr1qgnbZZWmhpOzC+YgREcfLA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/runtime-js/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-IwT1rX8ZamoYZv0clfswZemfXcIfk+YXwNsqXwzzh6TaMGagj/ZZl1llkn7ERQFq4EoLEoDGGkxqsrJjBp9NDQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/runtime-react-refresh/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-vDKO0rWqRzEpmvoZ4kkYUiSsTxT5NnH904BFPFxKI0wJCl6yEmPuEifmATo73OuYhP6jIP3Qfl1R4TtiDFPJ1Q==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/runtime-react-refresh/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-a6uuZWkl+mJur2WLZKmpEqq1P06tvRwqGefYbE26DWpwXwU9dLpfnv/nT0hqCmVDHd2TkMyCffolSmq1vY05ew==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 react-error-overlay: 6.0.9 react-refresh: 0.9.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/runtime-service-worker/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-uD2pAV0yV6+e7JaWH4KVPbG+zRCrxr/OACyS9tIh+Q/R1vRmh8zGM3yhdrcoiZ7tFOnM72vd6xY11eTrUsSVig==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/runtime-service-worker/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-Q3Q2O/axQbFi/5Z+BidLB3qhmYdZLTMDagZtsmyH7CktDkZVNV/0UoOGYlqoK06T4cww3XjLSEomXbBu9TlQKQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/source-map/2.0.2: - resolution: {integrity: sha512-NnUrPYLpYB6qyx2v6bcRPn/gVigmGG6M6xL8wIg/i0dP1GLkuY1nf+Hqdf63FzPTqqT7K3k6eE5yHPQVMO5jcA==} + /@parcel/source-map/2.1.1: + resolution: {integrity: sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==} engines: {node: ^12.18.3 || >=14} dependencies: detect-libc: 1.0.3 dev: true - /@parcel/transformer-babel/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-7iklDXXnKH1530+QbI+e4kIJ+Q1puA1ulRS10db3aUJMj5GnvXGDFwhSZ7+T1ps66QHO7cVO29VlbqiRDarH1Q==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-babel/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-ie+wFe9pucdnRyX2PTN9amOHrhr/IOwUEAfTz/3dPydOYCuX7ErEngCpI9fBzdYE2AV6/noEwC2Mjeoyz9mT2A==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 - browserslist: 4.20.3 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 + browserslist: 4.21.4 json5: 2.2.1 nullthrows: 1.1.1 semver: 5.7.1 @@ -3088,28 +3186,28 @@ packages: - '@parcel/core' dev: true - /@parcel/transformer-css/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-J4EpWK9spQpXyNCmKK8Xnane0xW/1B/EAmfp7Fiv7g+5yUjY4ODf4KUugvE+Eb2gekPkhOKNHermO2KrX0/PFA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-css/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-jCMQSfsxCoepblBAHCYMuNWNPQlqasoD6PfNftMdTlv12aUcnjNIYO9600TVLTL799CrEohljbXcfFn6hDGVWw==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/css': 1.12.2 - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 - browserslist: 4.20.3 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 + browserslist: 4.21.4 + lightningcss: 1.16.1 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/transformer-html/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-wYJl5rn81W+Rlk9oQwDJcjoVsWVDKyeri84FzmlGXOsg0EYgnqOiG+3MDM8GeZjfuGe5fuoum4eqZeS0WdUHXw==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-html/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-KLcZCWSIItZ1s12Sav3uvfTrwhX92craN9u7V3qUs8ld7ompTKsCdnf+gYmeCyISb5yiFDyYBvTGc1bOXvaDRQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 nullthrows: 1.1.1 posthtml: 0.16.6 posthtml-parser: 0.10.2 @@ -3119,71 +3217,71 @@ packages: - '@parcel/core' dev: true - /@parcel/transformer-image/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-mhi9/R5/ULhCkL2COVIKhNFoLDiZwQgprdaTJr5fnODggVxEX5o7ebFV6KNLMTEkwZUJWoB1hL0ziI0++DtoFA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-image/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-hJGsZxGlGEkiUvN8kCxA4DhB6/WrHzcIlZZYEgEien9pLctyc6np6idjdcyudPAhH3LwBPkiyeUfCvLAOA1zkA==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/core': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 + '@parcel/core': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 nullthrows: 1.1.1 dev: true - /@parcel/transformer-js/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-mzerR+D4rDomUSIk5RSTa2w+DXBdXUeQrpDO74WCDdpDi1lIl8ppFpqtmU7O6y6p8QsgkmS9b0g/vhcry6CJTA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-js/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-C5WTkDRiJGBB9tZa1mBsZwsqZjYEKkOa4mdVym3dMokwhFLUga8WtK7kGw4fmXIq41U8ip4orywj+Rd4mvGVWg==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/core': 2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/utils': 2.7.0 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 - '@swc/helpers': 0.4.2 - browserslist: 4.20.3 + '@parcel/core': 2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.8.0 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 + '@swc/helpers': 0.4.14 + browserslist: 4.21.4 detect-libc: 1.0.3 nullthrows: 1.1.1 - regenerator-runtime: 0.13.9 + regenerator-runtime: 0.13.11 semver: 5.7.1 dev: true - /@parcel/transformer-json/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-RQjuxBpYOch+kr4a0zi77KJtOLTPYRM7iq4NN80zKnA0r0dwDUCxZBtaj2l0O0o3R4MMJnm+ncP+cB7XR7dZYA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-json/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-Pp5gROSMpzFDEI6KA2APuSpft6eXZxFgTPV6Xx9pElqseod3iL5+RnpMNV/nv76Ai2bcMEiafus5Pb09vjHgbQ==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 json5: 2.2.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/transformer-mdx/2.7.0_qpbak7zubdfpxpruk62f7gjw7u: - resolution: {integrity: sha512-r51urO0kc8Hh0C83foFhq++V/bSZGk+IVWMrk/L6+igzOi5wc16wyLlGPTgd3fdSBpNs1zGNcnxl06TNyxuQ4g==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-mdx/2.8.0_smofjnbzoy3mcjjin2zix4yahi: + resolution: {integrity: sha512-GaLGEfpJJ1k3m9MFyAEpWbJRzBfw8XVE9L+LVHqAJh4U8NJWR96Tn1J9JAb0a34/IBiQ44msU0qTTOYInYO/Og==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} peerDependencies: '@mdx-js/react': ^1.6.22 dependencies: '@mdx-js/mdx': 1.6.22 '@mdx-js/react': 1.6.22_react@18.2.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - '@parcel/core' - supports-color dev: true - /@parcel/transformer-postcss/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-b6RskXBWf0MjpC9qjR2dQ1ZdRnlOiKYseG5CEovWCqM218RtdydFKz7jS+5Gxkb6qBtOG7zGPONXdPe+gTILcA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-postcss/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-45Ij+cgwXprd1sCLmaMIlCbPz3eEwolGHizgZmXl5l4yjlE2wGyzodhxLpBk1PWu7OxxWRbLnJIlvMYf7Vfw0g==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 clone: 2.1.2 nullthrows: 1.1.1 postcss-value-parser: 4.2.0 @@ -3192,12 +3290,12 @@ packages: - '@parcel/core' dev: true - /@parcel/transformer-posthtml/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-cP8YOiSynWJ1ycmBlhnnHeuQb2cwmklZ+BNyLUktj5p78kDy2de7VjX+dRNRHoW4H9OgEcSF4UEfDVVz5RYIhw==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-posthtml/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-KrkKBFDW5PNZpr2Ha711eIABQOiJQKvfwfVs3CVpJK5wSADkappDk7CQ0mISPjhamFJ6xx/sNsi7e871I8R9lg==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 nullthrows: 1.1.1 posthtml: 0.16.6 posthtml-parser: 0.10.2 @@ -3207,58 +3305,58 @@ packages: - '@parcel/core' dev: true - /@parcel/transformer-raw/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-sDnItWCFSDez0izK1i5cgv+kXzZTbcJh4rNpVIgmE1kBLvAz608sqgcCkavb2wVJIvLesxYM+5G4p1CwkDlZ1g==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-raw/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-uEbj+kE70vg2Gmdji/AIXPK13s5aQRw7X+xWs3vNpY2oymyMRHbfx1izJFWBh+kxu6Yo6q6qsekkh2rNHEHIUA==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/transformer-react-refresh-wrap/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-1vRmIJzyBA1nIiXTAU6tZExq2FvJj/2F0ft6KDw8GYPv0KjmdiPo/PmaZ7JeSVOM6SdXQIQCbTmp1vkMP7DtkA==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-react-refresh-wrap/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-d7G6wBdlwVXLkhC7EO/3UkUOfEOJvsIsQUCEujsrdFF+nfBElXw/TZ+KP8UkmrwMdD0spU/8cKoTyi5k19vt6w==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 react-refresh: 0.9.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/transformer-sass/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-6m2T6Y5eQLX7ckIeuOjXXIZbzhyovnl69AvJ2FujoWb2nA55H/kg6ZdbKjo3CfXkOfg9LyG3nVnOE5PMgMpRFQ==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-sass/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-DtjbC+4G7pdTaUgIesCoL67hiU88tC5LL4xHH0XspA9DmlFehJsDHOWc2q/pGfI22/Ck0eTOwNcMeYdkE0gCDw==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - sass: 1.49.9 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + sass: 1.56.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/transformer-svg-react/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-tRtLaNJZ7ThQ5hl9G2guckDfpVbTz/dM3I7vfoUXoLsKc0JFqvt+NL5tysN1aaxwgOTgDY1VbGgUJt9mNXYuwg==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-svg-react/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-/MZ9crDVA7vo1vxRrdijtBIEUQOe0zzSEv3/cUTK5ZKa2a3W8AXF1I9f7SoLz7nPOr7CDLR0jyELQJTQNRs24g==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 - '@svgr/core': 6.2.1 - '@svgr/plugin-jsx': 6.2.1_@svgr+core@6.2.1 - '@svgr/plugin-svgo': 6.2.0_@svgr+core@6.2.1 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 + '@svgr/core': 6.5.1 + '@svgr/plugin-jsx': 6.5.1_@svgr+core@6.5.1 + '@svgr/plugin-svgo': 6.5.1_@svgr+core@6.5.1 camelcase: 6.3.0 transitivePeerDependencies: - '@parcel/core' - supports-color dev: true - /@parcel/transformer-svg/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-ioER37zceuuE+K6ZrnjCyMUWEnv+63hIAFResc1OXxRhyt+7kzMz9ZqK0Mt6QMLwl1dxhkLmrU41n9IxzKZuSQ==} - engines: {node: '>= 12.0.0', parcel: ^2.7.0} + /@parcel/transformer-svg/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-8S6yZoUTCbHOnuWY3M50fscTpI8414945I44fmed+C1e36TnWem8FifuVtGkRZeR8pokF453lmmwWG1eH/4U3w==} + engines: {node: '>= 12.0.0', parcel: ^2.8.0} dependencies: - '@parcel/diagnostic': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/plugin': 2.7.0_@parcel+core@2.7.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/plugin': 2.8.0_@parcel+core@2.8.0 nullthrows: 1.1.1 posthtml: 0.16.6 posthtml-parser: 0.10.2 @@ -3268,53 +3366,53 @@ packages: - '@parcel/core' dev: true - /@parcel/types/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-+dhXVUnseTCpJvBTGMp0V6X13z6O/A/+CUtwEpMGZ8XSmZ4Gk44GvaTiBOp0bJpWG4fvCKp+UmC8PYbrDiiziw==} + /@parcel/types/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-DeN3vCnVl9onjtyWxpbP7LwRslVEko4kBaM7yILsuQjEnXmaIOsqIf6FQJOUOPBtQTFFNeQQ2qyf5XoO/rkJ8g==} dependencies: - '@parcel/cache': 2.7.0_@parcel+core@2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/fs': 2.7.0_@parcel+core@2.7.0 - '@parcel/package-manager': 2.7.0_@parcel+core@2.7.0 - '@parcel/source-map': 2.0.2 - '@parcel/workers': 2.7.0_@parcel+core@2.7.0 + '@parcel/cache': 2.8.0_@parcel+core@2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/fs': 2.8.0_@parcel+core@2.8.0 + '@parcel/package-manager': 2.8.0_@parcel+core@2.8.0 + '@parcel/source-map': 2.1.1 + '@parcel/workers': 2.8.0_@parcel+core@2.8.0 utility-types: 3.10.0 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/utils/2.7.0: - resolution: {integrity: sha512-jNZ5bIGg1r1RDRKi562o4kuVwnz+XJ2Ie3b0Zwrqwvgfj6AbRFIKzDd+h85dWWmcDYzKUbHp11u6VJl1u8Vapg==} + /@parcel/utils/2.8.0: + resolution: {integrity: sha512-r4ACsGtW7zkMUIgwQyOVtPAFiy8L81gbz4tMIRSqyQKnkW7oEHcQ3uN1/LPxj2yfkyQLmhJxmtptLUy9j53rcw==} engines: {node: '>= 12.0.0'} dependencies: - '@parcel/codeframe': 2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/hash': 2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/markdown-ansi': 2.7.0 - '@parcel/source-map': 2.0.2 + '@parcel/codeframe': 2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/hash': 2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/markdown-ansi': 2.8.0 + '@parcel/source-map': 2.1.1 chalk: 4.1.2 dev: true - /@parcel/watcher/2.0.5: - resolution: {integrity: sha512-x0hUbjv891omnkcHD7ZOhiyyUqUUR6MNjq89JhEI3BxppeKWAm6NPQsqqRrAkCJBogdT/o/My21sXtTI9rJIsw==} + /@parcel/watcher/2.0.7: + resolution: {integrity: sha512-gc3hoS6e+2XdIQ4HHljDB1l0Yx2EWh/sBBtCEFNKGSMlwASWeAQsOY/fPbxOBcZ/pg0jBh4Ga+4xHlZc4faAEQ==} engines: {node: '>= 10.0.0'} requiresBuild: true dependencies: node-addon-api: 3.2.1 - node-gyp-build: 4.3.0 + node-gyp-build: 4.5.0 dev: true - /@parcel/workers/2.7.0_@parcel+core@2.7.0: - resolution: {integrity: sha512-99VfaOX+89+RaoTSyH9ZQtkMBFZBFMvJmVJ/GeJT6QCd2wtKBStTHlaSnQOkLD/iRjJCNwV2xpZmm8YkTwV+hg==} + /@parcel/workers/2.8.0_@parcel+core@2.8.0: + resolution: {integrity: sha512-vAzoC/wPHLQnyy9P/TrSPftY8F3MhZqPTFi681mxVtLWA3t7wiNlw1zDVKRDP8m5XS1yQOr8Q56CAHyRexhc8g==} engines: {node: '>= 12.0.0'} peerDependencies: - '@parcel/core': ^2.7.0 + '@parcel/core': ^2.8.0 dependencies: - '@parcel/core': 2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/types': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/core': 2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/types': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 chrome-trace-event: 1.0.3 nullthrows: 1.1.1 dev: true @@ -3370,16 +3468,6 @@ packages: resolution: {integrity: sha512-Yykovind6xzqAqd0t5umrdAGPlGLTE80cy80UkEnbt8Zv5zEYTFzJSNPQ81TY8BSpRreubu1oE54iHBv2UVnTQ==} dev: true - /@shopify/jest-koa-mocks/5.0.0: - resolution: {integrity: sha512-keF5fgqAzWgC4O5uwUgQawp80IsDJdfyyMvWnIcsMaYw9CgURm4CW+v9NskUAn6AeaHd4Tkv+pWCFg/LRHHf4w==} - engines: {node: ^14.17.0 || >=16.0.0} - dependencies: - koa: 2.13.4 - node-mocks-http: 1.11.0 - transitivePeerDependencies: - - supports-color - dev: true - /@sideway/address/4.1.4: resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} dependencies: @@ -3394,35 +3482,13 @@ packages: resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} dev: true - /@silverhand/eslint-config-react/1.3.0_3jdvf2aalbcoibv3m53iflhmym: + /@silverhand/eslint-config-react/1.3.0_hwxyoluj7tfktess7f4itjwcee: resolution: {integrity: sha512-L6tzayeKo1RXTadIHbZKUJWzi5Pu0tjg6eoTw5mz+3FTLu4zBXK0+/jK6FeHwQ3lWt8Cj3HDIMvVG97yjoblPA==} engines: {node: ^16.0.0 || ^18.0.0} peerDependencies: stylelint: ^14.9.1 dependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni - eslint-config-xo-react: 0.27.0_nlhz3yu2pbp43ngjgjnh6mfwge - eslint-plugin-jsx-a11y: 6.6.1_eslint@8.21.0 - eslint-plugin-react: 7.31.10_eslint@8.21.0 - eslint-plugin-react-hooks: 4.6.0_eslint@8.21.0 - stylelint: 14.9.1 - stylelint-config-xo-scss: 0.15.0_eqpuutlgonckfyjzwkrpusdvaa - transitivePeerDependencies: - - eslint - - eslint-import-resolver-webpack - - postcss - - prettier - - supports-color - - typescript - dev: true - - /@silverhand/eslint-config-react/1.3.0_qoomm4vc6ijs52fnjlal4yoenm: - resolution: {integrity: sha512-L6tzayeKo1RXTadIHbZKUJWzi5Pu0tjg6eoTw5mz+3FTLu4zBXK0+/jK6FeHwQ3lWt8Cj3HDIMvVG97yjoblPA==} - engines: {node: ^16.0.0 || ^18.0.0} - peerDependencies: - stylelint: ^14.9.1 - dependencies: - '@silverhand/eslint-config': 1.3.0_swk2g7ygmfleszo5c33j4vooni + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa eslint-config-xo-react: 0.27.0_nlhz3yu2pbp43ngjgjnh6mfwge eslint-plugin-jsx-a11y: 6.6.1_eslint@8.21.0 eslint-plugin-react: 7.31.10_eslint@8.21.0 @@ -3438,7 +3504,29 @@ packages: - typescript dev: true - /@silverhand/eslint-config/1.3.0_swk2g7ygmfleszo5c33j4vooni: + /@silverhand/eslint-config-react/1.3.0_pzm7kshjahdwz2kcmmatnemr54: + resolution: {integrity: sha512-L6tzayeKo1RXTadIHbZKUJWzi5Pu0tjg6eoTw5mz+3FTLu4zBXK0+/jK6FeHwQ3lWt8Cj3HDIMvVG97yjoblPA==} + engines: {node: ^16.0.0 || ^18.0.0} + peerDependencies: + stylelint: ^14.9.1 + dependencies: + '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa + eslint-config-xo-react: 0.27.0_nlhz3yu2pbp43ngjgjnh6mfwge + eslint-plugin-jsx-a11y: 6.6.1_eslint@8.21.0 + eslint-plugin-react: 7.31.10_eslint@8.21.0 + eslint-plugin-react-hooks: 4.6.0_eslint@8.21.0 + stylelint: 14.9.1 + stylelint-config-xo-scss: 0.15.0_eqpuutlgonckfyjzwkrpusdvaa + transitivePeerDependencies: + - eslint + - eslint-import-resolver-webpack + - postcss + - prettier + - supports-color + - typescript + dev: true + + /@silverhand/eslint-config/1.3.0_eu7dlo3qq5moigliolva3udaxa: resolution: {integrity: sha512-0+SXJXAkUe1pg2DNn3JCEo99Weev07chQsL2iSCramXeMKjEk1R1UKjgQJM9saUGF7ovY4hlE/JjFD3PFId4DQ==} engines: {node: ^16.0.0 || ^18.0.0} peerDependencies: @@ -3446,12 +3534,12 @@ packages: prettier: ^2.7.1 dependencies: '@silverhand/eslint-plugin-fp': 2.5.0_eslint@8.21.0 - '@typescript-eslint/eslint-plugin': 5.40.0_bomoubwgcm5gub6ncofkqpat4u - '@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/eslint-plugin': 5.40.0_ryuuuwjgkm45eys4ms27dfx6xm + '@typescript-eslint/parser': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi eslint: 8.21.0 eslint-config-prettier: 8.5.0_eslint@8.21.0 eslint-config-xo: 0.42.0_eslint@8.21.0 - eslint-config-xo-typescript: 0.53.0_4y2fwmtil766jscqcpwrpkqfje + eslint-config-xo-typescript: 0.53.0_cqx3bgaw6gtoyrp3gyom2ucbru eslint-import-resolver-typescript: 3.5.1_jatgrcxl4x7ywe7ak6cnjca2ae eslint-plugin-consistent-default-export-name: 0.0.15 eslint-plugin-eslint-comments: 3.2.0_eslint@8.21.0 @@ -3490,7 +3578,7 @@ packages: lodash.orderby: 4.6.0 lodash.pick: 4.4.0 - /@silverhand/jest-config/1.2.2_zapogttls25djihwjkusccjjym: + /@silverhand/jest-config/1.2.2_ky6c64xxalg2hsll4xx3evq2dy: resolution: {integrity: sha512-sCOIHN3kIG9nyySkDao8nz6HK8VhGoUV4WG1CCriDDeGTqbHs4IprzTp1p+ChFdC8JGBCElQC0cIFrWYTFnTAQ==} engines: {node: ^16.0.0 || ^18.0.0} peerDependencies: @@ -3502,7 +3590,7 @@ packages: jest: 29.1.2_k5ytkvaprncdyzidqqws5bqksq jest-matcher-specific-error: 1.0.0 jest-transform-stub: 2.0.0 - ts-jest: 29.0.3_37jxomqt5oevoqzq6g3r6n3ili + ts-jest: 29.0.3_5xcodqox2j6ogkdcajmxw2vjdu transitivePeerDependencies: - '@babel/core' - babel-jest @@ -3510,23 +3598,23 @@ packages: - typescript dev: true - /@silverhand/ts-config-react/1.2.1_typescript@4.7.4: + /@silverhand/ts-config-react/1.2.1_typescript@4.9.4: resolution: {integrity: sha512-40BYg5gqzThCmXw+SJXnlWvSUWpFKsdfVHlguJXgdB1l8O6Yqe1jcwjHrNC/yBy8jgLInhLXuaFs86/p1g0m+Q==} engines: {node: ^16.0.0 || ^18.0.0} peerDependencies: typescript: ^4.7.4 dependencies: - '@silverhand/ts-config': 1.2.1_typescript@4.7.4 - typescript: 4.7.4 + '@silverhand/ts-config': 1.2.1_typescript@4.9.4 + typescript: 4.9.4 dev: true - /@silverhand/ts-config/1.2.1_typescript@4.7.4: + /@silverhand/ts-config/1.2.1_typescript@4.9.4: resolution: {integrity: sha512-Lm5Ydb45qKmXvlOfQfSb+1WHrdL5IBtzt+AMOR5h528H073FLzaazLiaDo4noBVT9PAVtO7kG9qjwSPzHf0k9Q==} engines: {node: ^16.0.0 || ^18.0.0} peerDependencies: typescript: ^4.7.4 dependencies: - typescript: 4.7.4 + typescript: 4.9.4 dev: true /@sinclair/typebox/0.24.46: @@ -3536,6 +3624,11 @@ packages: /@sindresorhus/is/4.2.0: resolution: {integrity: sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==} engines: {node: '>=10'} + dev: false + + /@sindresorhus/is/5.3.0: + resolution: {integrity: sha512-CX6t4SYQ37lzxicAqsBtxA3OseeoVrh9cSJ5PFYam0GksYlupRfy1A+Q4aYD3zvcfECLc0zO2u+ZnR2UYKvCrw==} + engines: {node: '>=14.16'} /@sinonjs/commons/1.8.3: resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==} @@ -3543,6 +3636,18 @@ packages: type-detect: 4.0.8 dev: true + /@sinonjs/commons/2.0.0: + resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers/7.1.2: + resolution: {integrity: sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==} + dependencies: + '@sinonjs/commons': 1.8.3 + dev: true + /@sinonjs/fake-timers/8.1.0: resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==} dependencies: @@ -3555,145 +3660,159 @@ packages: '@sinonjs/commons': 1.8.3 dev: true - /@svgr/babel-plugin-add-jsx-attribute/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-MdPdhdWLtQsjd29Wa4pABdhWbaRMACdM1h31BY+c6FghTZqNGT7pEYdBoaGeKtdTOBC/XNFQaKVj+r/Ei2ryWA==} + /@sinonjs/samsam/7.0.1: + resolution: {integrity: sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==} + dependencies: + '@sinonjs/commons': 2.0.0 + lodash.get: 4.4.2 + type-detect: 4.0.8 + dev: true + + /@sinonjs/text-encoding/0.7.2: + resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} + dev: true + + /@svgr/babel-plugin-add-jsx-attribute/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-remove-jsx-attribute/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-aVdtfx9jlaaxc3unA6l+M9YRnKIZjOhQPthLKqmTXC8UVkBLDRGwPKo+r8n3VZN8B34+yVajzPTZ+ptTSuZZCw==} + /@svgr/babel-plugin-remove-jsx-attribute/6.5.0_@babel+core@7.20.2: + resolution: {integrity: sha512-8zYdkym7qNyfXpWvu4yq46k41pyNM9SOstoWhKlm+IfdCE1DdnRKeMUPsWIEO/DEkaWxJ8T9esNdG3QwQ93jBA==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-remove-jsx-empty-expression/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-Ccj42ApsePD451AZJJf1QzTD1B/BOU392URJTeXFxSK709i0KUsGtbwyiqsKu7vsYxpTM0IA5clAKDyf9RCZyA==} + /@svgr/babel-plugin-remove-jsx-empty-expression/6.5.0_@babel+core@7.20.2: + resolution: {integrity: sha512-NFdxMq3xA42Kb1UbzCVxplUc0iqSyM9X8kopImvFnB+uSDdzIHOdbs1op8ofAvVRtbg4oZiyRl3fTYeKcOe9Iw==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-replace-jsx-attribute-value/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-88V26WGyt1Sfd1emBYmBJRWMmgarrExpKNVmI9vVozha4kqs6FzQJ/Kp5+EYli1apgX44518/0+t9+NU36lThQ==} + /@svgr/babel-plugin-replace-jsx-attribute-value/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-8DPaVVE3fd5JKuIC29dqyMB54sA6mfgki2H2+swh+zNJoynC8pMPzOkidqHOSc6Wj032fhl8Z0TVn1GiPpAiJg==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-svg-dynamic-title/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-F7YXNLfGze+xv0KMQxrl2vkNbI9kzT9oDK55/kUuymh1ACyXkMV+VZWX1zEhSTfEKh7VkHVZGmVtHg8eTZ6PRg==} + /@svgr/babel-plugin-svg-dynamic-title/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-FwOEi0Il72iAzlkaHrlemVurgSQRDFbk0OC8dSvD5fSBPHltNh7JtLsxmZUhjYBZo2PpcU/RJvvi6Q0l7O7ogw==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-svg-em-dimensions/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-+rghFXxdIqJNLQK08kwPBD3Z22/0b2tEZ9lKiL/yTfuyj1wW8HUXu4bo/XkogATIYuXSghVQOOCwURXzHGKyZA==} + /@svgr/babel-plugin-svg-em-dimensions/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-gWGsiwjb4tw+ITOJ86ndY/DZZ6cuXMNE/SjcDRg+HLuCmwpcjOktwRF9WgAiycTqJD/QXqL2f8IzE2Rzh7aVXA==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-transform-react-native-svg/6.0.0_@babel+core@7.17.9: - resolution: {integrity: sha512-VaphyHZ+xIKv5v0K0HCzyfAaLhPGJXSk2HkpYfXIOKb7DjLBv0soHDxNv6X0vr2titsxE7klb++u7iOf7TSrFQ==} + /@svgr/babel-plugin-transform-react-native-svg/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-2jT3nTayyYP7kI6aGutkyfJ7UMGtuguD72OjeGLwVNyfPRBD8zQthlvL+fAbAKk5n9ZNcvFkp/b1lZ7VsYqVJg==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-plugin-transform-svg-component/6.2.0_@babel+core@7.17.9: - resolution: {integrity: sha512-bhYIpsORb++wpsp91fymbFkf09Z/YEKR0DnFjxvN+8JHeCUD2unnh18jIMKnDJTWtvpTaGYPXELVe4OOzFI0xg==} + /@svgr/babel-plugin-transform-svg-component/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-a1p6LF5Jt33O3rZoVRBqdxL350oge54iZWHNI6LJB5tQ7EelvD/Mb1mfBiZNAan0dt4i3VArkFRjA4iObuNykQ==} engines: {node: '>=12'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 + '@babel/core': 7.20.2 dev: true - /@svgr/babel-preset/6.2.0_@babel+core@7.17.9: - resolution: {integrity: sha512-4WQNY0J71JIaL03DRn0vLiz87JXx0b9dYm2aA8XHlQJQoixMl4r/soYHm8dsaJZ3jWtkCiOYy48dp9izvXhDkQ==} + /@svgr/babel-preset/6.5.1_@babel+core@7.20.2: + resolution: {integrity: sha512-6127fvO/FF2oi5EzSQOAjo1LE3OtNVh11R+/8FXa+mHx1ptAaS4cknIjnUA7e6j6fwGGJ17NzaTJFUwOV2zwCw==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.17.9 - '@svgr/babel-plugin-add-jsx-attribute': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-remove-jsx-attribute': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-remove-jsx-empty-expression': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-replace-jsx-attribute-value': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-svg-dynamic-title': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-svg-em-dimensions': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-transform-react-native-svg': 6.0.0_@babel+core@7.17.9 - '@svgr/babel-plugin-transform-svg-component': 6.2.0_@babel+core@7.17.9 + '@babel/core': 7.20.2 + '@svgr/babel-plugin-add-jsx-attribute': 6.5.1_@babel+core@7.20.2 + '@svgr/babel-plugin-remove-jsx-attribute': 6.5.0_@babel+core@7.20.2 + '@svgr/babel-plugin-remove-jsx-empty-expression': 6.5.0_@babel+core@7.20.2 + '@svgr/babel-plugin-replace-jsx-attribute-value': 6.5.1_@babel+core@7.20.2 + '@svgr/babel-plugin-svg-dynamic-title': 6.5.1_@babel+core@7.20.2 + '@svgr/babel-plugin-svg-em-dimensions': 6.5.1_@babel+core@7.20.2 + '@svgr/babel-plugin-transform-react-native-svg': 6.5.1_@babel+core@7.20.2 + '@svgr/babel-plugin-transform-svg-component': 6.5.1_@babel+core@7.20.2 dev: true - /@svgr/core/6.2.1: - resolution: {integrity: sha512-NWufjGI2WUyrg46mKuySfviEJ6IxHUOm/8a3Ph38VCWSp+83HBraCQrpEM3F3dB6LBs5x8OElS8h3C0oOJaJAA==} + /@svgr/core/6.5.1: + resolution: {integrity: sha512-/xdLSWxK5QkqG524ONSjvg3V/FkNyCv538OIBdQqPNaAta3AsXj/Bd2FbvR87yMbXO2hFSWiAe/Q6IkVPDw+mw==} engines: {node: '>=10'} dependencies: - '@svgr/plugin-jsx': 6.2.1_@svgr+core@6.2.1 + '@babel/core': 7.20.2 + '@svgr/babel-preset': 6.5.1_@babel+core@7.20.2 + '@svgr/plugin-jsx': 6.5.1_@svgr+core@6.5.1 camelcase: 6.3.0 - cosmiconfig: 7.0.1 + cosmiconfig: 7.1.0 transitivePeerDependencies: - supports-color dev: true - /@svgr/hast-util-to-babel-ast/6.2.1: - resolution: {integrity: sha512-pt7MMkQFDlWJVy9ULJ1h+hZBDGFfSCwlBNW1HkLnVi7jUhyEXUaGYWi1x6bM2IXuAR9l265khBT4Av4lPmaNLQ==} + /@svgr/hast-util-to-babel-ast/6.5.1: + resolution: {integrity: sha512-1hnUxxjd83EAxbL4a0JDJoD3Dao3hmjvyvyEV8PzWmLK3B9m9NPlW7GKjFyoWE8nM7HnXzPcmmSyOW8yOddSXw==} engines: {node: '>=10'} dependencies: - '@babel/types': 7.18.2 - entities: 3.0.1 + '@babel/types': 7.20.2 + entities: 4.4.0 dev: true - /@svgr/plugin-jsx/6.2.1_@svgr+core@6.2.1: - resolution: {integrity: sha512-u+MpjTsLaKo6r3pHeeSVsh9hmGRag2L7VzApWIaS8imNguqoUwDq/u6U/NDmYs/KAsrmtBjOEaAAPbwNGXXp1g==} + /@svgr/plugin-jsx/6.5.1_@svgr+core@6.5.1: + resolution: {integrity: sha512-+UdQxI3jgtSjCykNSlEMuy1jSRQlGC7pqBCPvkG/2dATdWo082zHTTK3uhnAju2/6XpE6B5mZ3z4Z8Ns01S8Gw==} engines: {node: '>=10'} peerDependencies: '@svgr/core': ^6.0.0 dependencies: - '@babel/core': 7.17.9 - '@svgr/babel-preset': 6.2.0_@babel+core@7.17.9 - '@svgr/core': 6.2.1 - '@svgr/hast-util-to-babel-ast': 6.2.1 + '@babel/core': 7.20.2 + '@svgr/babel-preset': 6.5.1_@babel+core@7.20.2 + '@svgr/core': 6.5.1 + '@svgr/hast-util-to-babel-ast': 6.5.1 svg-parser: 2.0.4 transitivePeerDependencies: - supports-color dev: true - /@svgr/plugin-svgo/6.2.0_@svgr+core@6.2.1: - resolution: {integrity: sha512-oDdMQONKOJEbuKwuy4Np6VdV6qoaLLvoY86hjvQEgU82Vx1MSWRyYms6Sl0f+NtqxLI/rDVufATbP/ev996k3Q==} + /@svgr/plugin-svgo/6.5.1_@svgr+core@6.5.1: + resolution: {integrity: sha512-omvZKf8ixP9z6GWgwbtmP9qQMPX4ODXi+wzbVZgomNFsUIlHA1sf4fThdwTWSsZGgvGAG6yE+b/F5gWUkcZ/iQ==} engines: {node: '>=10'} peerDependencies: - '@svgr/core': ^6.0.0 + '@svgr/core': '*' dependencies: - '@svgr/core': 6.2.1 - cosmiconfig: 7.0.1 + '@svgr/core': 6.5.1 + cosmiconfig: 7.1.0 deepmerge: 4.2.2 svgo: 2.8.0 dev: true - /@swc/helpers/0.4.2: - resolution: {integrity: sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw==} + /@swc/helpers/0.4.14: + resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} dependencies: - tslib: 2.4.0 + tslib: 2.4.1 dev: true /@szmarczak/http-timer/4.0.6: @@ -3701,6 +3820,13 @@ packages: engines: {node: '>=10'} dependencies: defer-to-connect: 2.0.1 + dev: false + + /@szmarczak/http-timer/5.0.1: + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + dependencies: + defer-to-connect: 2.0.1 /@testing-library/dom/8.11.1: resolution: {integrity: sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg==} @@ -3817,6 +3943,7 @@ packages: '@types/keyv': 3.1.3 '@types/node': 17.0.23 '@types/responselike': 1.0.0 + dev: false /@types/color-convert/2.0.0: resolution: {integrity: sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==} @@ -4001,6 +4128,7 @@ packages: resolution: {integrity: sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==} dependencies: '@types/node': 17.0.23 + dev: false /@types/koa-compose/3.2.5: resolution: {integrity: sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==} @@ -4198,6 +4326,7 @@ packages: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: '@types/node': 17.0.23 + dev: false /@types/retry/0.12.1: resolution: {integrity: sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==} @@ -4222,6 +4351,16 @@ packages: '@types/node': 17.0.23 dev: true + /@types/sinon/10.0.13: + resolution: {integrity: sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==} + dependencies: + '@types/sinonjs__fake-timers': 8.1.2 + dev: true + + /@types/sinonjs__fake-timers/8.1.2: + resolution: {integrity: sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==} + dev: true + /@types/stack-utils/2.0.1: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true @@ -4288,7 +4427,7 @@ packages: dev: true optional: true - /@typescript-eslint/eslint-plugin/5.40.0_bomoubwgcm5gub6ncofkqpat4u: + /@typescript-eslint/eslint-plugin/5.40.0_ryuuuwjgkm45eys4ms27dfx6xm: resolution: {integrity: sha512-FIBZgS3DVJgqPwJzvZTuH4HNsZhHMa9SjxTKAZTlMsPw/UzpEjcf9f4dfgDJEHjK+HboUJo123Eshl6niwEm/Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -4299,22 +4438,22 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/parser': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi '@typescript-eslint/scope-manager': 5.40.0 - '@typescript-eslint/type-utils': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq - '@typescript-eslint/utils': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/type-utils': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi + '@typescript-eslint/utils': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi debug: 4.3.4 eslint: 8.21.0 ignore: 5.2.0 regexpp: 3.2.0 semver: 7.3.8 - tsutils: 3.21.0_typescript@4.7.4 - typescript: 4.7.4 + tsutils: 3.21.0_typescript@4.9.4 + typescript: 4.9.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser/5.40.0_qugx7qdu5zevzvxaiqyxfiwquq: + /@typescript-eslint/parser/5.40.0_ggwlz5rjjuds5feuls6rqqovzi: resolution: {integrity: sha512-Ah5gqyX2ySkiuYeOIDg7ap51/b63QgWZA7w6AHtFrag7aH0lRQPbLzUjk0c9o5/KZ6JRkTTDKShL4AUrQa6/hw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -4326,10 +4465,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.40.0 '@typescript-eslint/types': 5.40.0 - '@typescript-eslint/typescript-estree': 5.40.0_typescript@4.7.4 + '@typescript-eslint/typescript-estree': 5.40.0_typescript@4.9.4 debug: 4.3.4 eslint: 8.21.0 - typescript: 4.7.4 + typescript: 4.9.4 transitivePeerDependencies: - supports-color dev: true @@ -4342,7 +4481,7 @@ packages: '@typescript-eslint/visitor-keys': 5.40.0 dev: true - /@typescript-eslint/type-utils/5.40.0_qugx7qdu5zevzvxaiqyxfiwquq: + /@typescript-eslint/type-utils/5.40.0_ggwlz5rjjuds5feuls6rqqovzi: resolution: {integrity: sha512-nfuSdKEZY2TpnPz5covjJqav+g5qeBqwSHKBvz7Vm1SAfy93SwKk/JeSTymruDGItTwNijSsno5LhOHRS1pcfw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -4352,12 +4491,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.40.0_typescript@4.7.4 - '@typescript-eslint/utils': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/typescript-estree': 5.40.0_typescript@4.9.4 + '@typescript-eslint/utils': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi debug: 4.3.4 eslint: 8.21.0 - tsutils: 3.21.0_typescript@4.7.4 - typescript: 4.7.4 + tsutils: 3.21.0_typescript@4.9.4 + typescript: 4.9.4 transitivePeerDependencies: - supports-color dev: true @@ -4367,7 +4506,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree/5.40.0_typescript@4.7.4: + /@typescript-eslint/typescript-estree/5.40.0_typescript@4.9.4: resolution: {integrity: sha512-b0GYlDj8TLTOqwX7EGbw2gL5EXS2CPEWhF9nGJiGmEcmlpNBjyHsTwbqpyIEPVpl6br4UcBOYlcI2FJVtJkYhg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -4382,13 +4521,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.3.8 - tsutils: 3.21.0_typescript@4.7.4 - typescript: 4.7.4 + tsutils: 3.21.0_typescript@4.9.4 + typescript: 4.9.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils/5.40.0_qugx7qdu5zevzvxaiqyxfiwquq: + /@typescript-eslint/utils/5.40.0_ggwlz5rjjuds5feuls6rqqovzi: resolution: {integrity: sha512-MO0y3T5BQ5+tkkuYZJBjePewsY+cQnfkYeRqS6tPh28niiIwPnQ1t59CSRcs1ZwJJNOdWw7rv9pF8aP58IMihA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -4397,7 +4536,7 @@ packages: '@types/json-schema': 7.0.11 '@typescript-eslint/scope-manager': 5.40.0 '@typescript-eslint/types': 5.40.0 - '@typescript-eslint/typescript-estree': 5.40.0_typescript@4.7.4 + '@typescript-eslint/typescript-estree': 5.40.0_typescript@4.9.4 eslint: 8.21.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0_eslint@8.21.0 @@ -4431,8 +4570,8 @@ packages: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: true - /abortcontroller-polyfill/1.7.3: - resolution: {integrity: sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==} + /abortcontroller-polyfill/1.7.5: + resolution: {integrity: sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==} dev: true /accepts/1.3.7: @@ -4441,6 +4580,7 @@ packages: dependencies: mime-types: 2.1.35 negotiator: 0.6.2 + dev: false /accepts/1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} @@ -4476,6 +4616,12 @@ packages: hasBin: true dev: true + /acorn/8.8.1: + resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /agent-base/6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4542,7 +4688,6 @@ packages: /ansi-regex/6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: true /ansi-styles/3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} @@ -4574,6 +4719,14 @@ packages: picomatch: 2.3.1 dev: true + /anymatch/3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + /arg/4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -4722,6 +4875,24 @@ packages: - supports-color dev: true + /babel-jest/29.3.1_@babel+core@7.19.3: + resolution: {integrity: sha512-aard+xnMoxgjwV70t0L6wkW/3HQQtV+O0PEimxKgzNqCJnbYmroPojdP2tqKSOAt8QAKV/uSZU8851M7B5+fcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + dependencies: + '@babel/core': 7.19.3 + '@jest/transform': 29.3.1 + '@types/babel__core': 7.1.19 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.2.0_@babel+core@7.19.3 + chalk: 4.1.2 + graceful-fs: 4.2.10 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /babel-plugin-apply-mdx-type-prop/1.6.22_@babel+core@7.12.9: resolution: {integrity: sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==} peerDependencies: @@ -4761,6 +4932,16 @@ packages: '@types/babel__traverse': 7.18.2 dev: true + /babel-plugin-jest-hoist/29.2.0: + resolution: {integrity: sha512-TnspP2WNiR3GLfCsUNHqeXw0RoQ2f9U5hQ5L3XFpwuO8htQmSrhh8qsB6vi5Yi8+kuynN1yjDjQsPfkebmB6ZA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/template': 7.18.10 + '@babel/types': 7.19.4 + '@types/babel__core': 7.1.19 + '@types/babel__traverse': 7.18.2 + dev: true + /babel-plugin-macros/2.8.0: resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==} dependencies: @@ -4800,6 +4981,17 @@ packages: babel-preset-current-node-syntax: 1.0.1_@babel+core@7.19.3 dev: true + /babel-preset-jest/29.2.0_@babel+core@7.19.3: + resolution: {integrity: sha512-z9JmMJppMxNv8N7fNRHvhMg9cvIkMxQBXgFkane3yKVEvEOP+kB50lk8DFRvF9PGqbyXxlmebKWhuDORO8RgdA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.19.3 + babel-plugin-jest-hoist: 29.2.0 + babel-preset-current-node-syntax: 1.0.1_@babel+core@7.19.3 + dev: true + /bail/1.0.5: resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==} dev: true @@ -4843,6 +5035,14 @@ packages: inherits: 2.0.4 readable-stream: 3.6.0 + /bl/5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.0 + dev: false + /bluebird/3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -4872,18 +5072,6 @@ packages: wcwidth: 1.0.1 dev: true - /browserslist/4.20.3: - resolution: {integrity: sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - dependencies: - caniuse-lite: 1.0.30001344 - electron-to-chromium: 1.4.141 - escalade: 3.1.1 - node-releases: 2.0.5 - picocolors: 1.0.0 - dev: true - /browserslist/4.21.4: resolution: {integrity: sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4925,6 +5113,13 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /buffer/6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + /bufferput/0.1.3: resolution: {integrity: sha512-nmPV88vDNzf0VMU1bdQ4A1oBlRR9y+CXfwWKfyKUgI2ZIkvreNzLMM3tkz0Lapb6f+Cz1V001UWRBsoGVCjqdw==} engines: {node: '>=0.3.0'} @@ -4950,16 +5145,34 @@ packages: dependencies: mime-types: 2.1.35 ylru: 1.2.1 + dev: false /cacheable-lookup/5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} + dev: false /cacheable-lookup/6.0.4: resolution: {integrity: sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==} engines: {node: '>=10.6.0'} dev: false + /cacheable-lookup/7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + /cacheable-request/10.2.2: + resolution: {integrity: sha512-KxjQZM3UIo7/J6W4sLpwFvu1GB3Whv8NtZ8ZrUL284eiQjiXeeqWTdhixNrp/NLZ/JNuFBo6BD4ZaO8ZJ5BN8Q==} + engines: {node: '>=14.16'} + dependencies: + '@types/http-cache-semantics': 4.0.1 + get-stream: 6.0.1 + http-cache-semantics: 4.1.0 + keyv: 4.5.2 + mimic-response: 4.0.0 + normalize-url: 7.2.0 + responselike: 3.0.0 + /cacheable-request/7.0.2: resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==} engines: {node: '>=8'} @@ -4971,6 +5184,7 @@ packages: lowercase-keys: 2.0.0 normalize-url: 6.1.0 responselike: 2.0.0 + dev: false /call-bind/1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} @@ -5006,22 +5220,28 @@ packages: type-fest: 1.4.0 dev: true + /camelcase-keys/8.0.2: + resolution: {integrity: sha512-qMKdlOfsjlezMqxkUGGMaWWs17i2HoL15tM+wtx8ld4nLrUwU58TFdvyGOz/piNP842KeO8yXvggVQSdQ828NA==} + engines: {node: '>=14.16'} + dependencies: + camelcase: 7.0.0 + map-obj: 4.3.0 + quick-lru: 6.1.1 + type-fest: 2.19.0 + dev: true + /camelcase/5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} dev: true - /camelcase/6.2.1: - resolution: {integrity: sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA==} - engines: {node: '>=10'} - dev: true - /camelcase/6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - /caniuse-lite/1.0.30001344: - resolution: {integrity: sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g==} + /camelcase/7.0.0: + resolution: {integrity: sha512-JToIvOmz6nhGsUhAYScbo2d6Py5wojjNfoxoc2mEVLUdJ70gJK2gnd+ABY1Tc3sVMyK7QDPtN0T/XdlCQWITyQ==} + engines: {node: '>=14.16'} dev: true /caniuse-lite/1.0.30001419: @@ -5051,6 +5271,11 @@ packages: ansi-styles: 4.3.0 supports-color: 7.2.0 + /chalk/5.1.2: + resolution: {integrity: sha512-E5CkT4jWURs1Vy5qGJye+XwCkNj7Od3Af7CP6SujMetSMkLs8Do2RWJK5yx1wamHV/op8Rz+9rltjaTQWDnEFQ==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false + /char-regex/1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -5079,7 +5304,7 @@ packages: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} dependencies: - anymatch: 3.1.2 + anymatch: 3.1.3 braces: 3.0.2 glob-parent: 5.1.2 is-binary-path: 2.1.0 @@ -5142,6 +5367,13 @@ packages: dependencies: restore-cursor: 3.1.0 + /cli-cursor/4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + restore-cursor: 4.0.0 + dev: false + /cli-spinners/2.6.1: resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} engines: {node: '>=6'} @@ -5214,6 +5446,7 @@ packages: resolution: {integrity: sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==} dependencies: mimic-response: 1.0.1 + dev: false /clone/1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} @@ -5353,6 +5586,7 @@ packages: /content-type/1.0.4: resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==} engines: {node: '>= 0.6'} + dev: false /conventional-changelog-angular/5.0.13: resolution: {integrity: sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA==} @@ -5384,16 +5618,14 @@ packages: through2: 4.0.2 dev: true - /convert-source-map/1.8.0: - resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==} - dependencies: - safe-buffer: 5.1.2 - dev: true - /convert-source-map/1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} dev: true + /convert-source-map/2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true + /cookiejar/2.1.3: resolution: {integrity: sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==} dev: true @@ -5404,6 +5636,7 @@ packages: dependencies: depd: 2.0.0 keygrip: 1.1.0 + dev: false /copyfiles/2.4.1: resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} @@ -5432,7 +5665,7 @@ packages: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: true - /cosmiconfig-typescript-loader/2.0.0_bjctuninx3nzqxltyvshqte2ni: + /cosmiconfig-typescript-loader/2.0.0_73inix45wpcdjnppmmovzbfudu: resolution: {integrity: sha512-2NlGul/E3vTQEANqPziqkA01vfiuUU8vT0jZAuUIjEW8u3eCcnCQWLggapCjhbF76s7KQF0fM0kXSKmzaDaG1g==} engines: {node: '>=12', npm: '>=6'} peerDependencies: @@ -5441,8 +5674,8 @@ packages: dependencies: '@types/node': 17.0.23 cosmiconfig: 7.0.1 - ts-node: 10.7.0_bjctuninx3nzqxltyvshqte2ni - typescript: 4.7.4 + ts-node: 10.7.0_73inix45wpcdjnppmmovzbfudu + typescript: 4.9.4 transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -5470,6 +5703,17 @@ packages: yaml: 1.10.2 dev: true + /cosmiconfig/7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.0 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: true + /crack-json/1.3.0: resolution: {integrity: sha512-JfZ9NPLsU9ejTYgZ7fM+5TIMfTwROTxpi2Twh597GxmiVDwIGZSjaor+zsQBKZ0mmCKOFb9EZZLVeKNf/5UaGg==} engines: {node: '>=8.0'} @@ -5524,14 +5768,14 @@ packages: engines: {node: '>=12.22'} dev: true - /css-select/4.2.1: - resolution: {integrity: sha512-/aUslKhzkTNCQUB2qTX84lVmfia9NyjP3WpDGtj/WxhwBzWBYUV3DgUpurHTme8UTPcPlAD1DJ+b0nN/t50zDQ==} + /css-select/4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} dependencies: boolbase: 1.0.0 - css-what: 5.1.0 + css-what: 6.1.0 domhandler: 4.3.1 domutils: 2.8.0 - nth-check: 2.0.1 + nth-check: 2.1.1 dev: true /css-tree/1.1.3: @@ -5546,8 +5790,8 @@ packages: resolution: {integrity: sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==} dev: true - /css-what/5.1.0: - resolution: {integrity: sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==} + /css-what/6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} dev: true @@ -5728,18 +5972,6 @@ packages: supports-color: 5.5.0 dev: true - /debug/4.3.3: - resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - dev: true - /debug/4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -5800,6 +6032,7 @@ packages: /deep-equal/1.0.1: resolution: {integrity: sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=} + dev: false /deep-is/0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -5853,6 +6086,7 @@ packages: /delegates/1.0.0: resolution: {integrity: sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=} + dev: false /depd/1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} @@ -5861,6 +6095,7 @@ packages: /depd/2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dev: false /dequal/2.0.2: resolution: {integrity: sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==} @@ -5869,6 +6104,7 @@ packages: /destroy/1.0.4: resolution: {integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=} + dev: false /detab/2.0.4: resolution: {integrity: sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==} @@ -5911,6 +6147,11 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /diff-sequences/29.3.1: + resolution: {integrity: sha512-hlM3QR272NXCi4pq+N4Kok4kOp6EsgOM3ZSpJI7Da3UAs+Ttsi8MRmB6trM/lhyzUxGfOgnpkHtgqm5Q/CTcfQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + /diff/4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -5960,8 +6201,8 @@ packages: '@babel/runtime': 7.18.3 dev: true - /dom-serializer/1.3.2: - resolution: {integrity: sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==} + /dom-serializer/1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} dependencies: domelementtype: 2.3.0 domhandler: 4.3.1 @@ -5989,7 +6230,7 @@ packages: /domutils/2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} dependencies: - dom-serializer: 1.3.2 + dom-serializer: 1.4.1 domelementtype: 2.3.0 domhandler: 4.3.1 dev: true @@ -6026,6 +6267,7 @@ packages: /ee-first/1.1.1: resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + dev: false /ejs/3.1.8: resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} @@ -6035,10 +6277,6 @@ packages: jake: 10.8.5 dev: false - /electron-to-chromium/1.4.141: - resolution: {integrity: sha512-mfBcbqc0qc6RlxrsIgLG2wCqkiPAjEezHxGTu7p3dHHFOurH4EjS9rFZndX5axC8264rI1Pcbw8uQP39oZckeA==} - dev: true - /electron-to-chromium/1.4.281: resolution: {integrity: sha512-yer0w5wCYdFoZytfmbNhwiGI/3cW06+RV7E23ln4490DVMxs7PvYpbsrSmAiBn/V6gode8wvJlST2YfWgvzWIg==} dev: true @@ -6048,6 +6286,11 @@ packages: engines: {node: '>=12'} dev: true + /emittery/0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + dev: true + /emoji-regex/7.0.3: resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} dev: false @@ -6062,6 +6305,7 @@ packages: /encodeurl/1.0.2: resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=} engines: {node: '>= 0.8'} + dev: false /end-of-stream/1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -6156,6 +6400,7 @@ packages: /escape-html/1.0.3: resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=} + dev: false /escape-string-regexp/1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} @@ -6219,7 +6464,7 @@ packages: eslint-plugin-react-hooks: 4.6.0_eslint@8.21.0 dev: true - /eslint-config-xo-typescript/0.53.0_4y2fwmtil766jscqcpwrpkqfje: + /eslint-config-xo-typescript/0.53.0_cqx3bgaw6gtoyrp3gyom2ucbru: resolution: {integrity: sha512-IJ1n70egMPTou/41HoGGFbLf/2WCsVW5lSUxOSklrR8T1221fMRPVJxIVZ3evr8R+N5wR6uzg/0uzSymwWA5Bg==} engines: {node: '>=12'} peerDependencies: @@ -6228,10 +6473,10 @@ packages: eslint: '>=8.0.0' typescript: '>=4.4' dependencies: - '@typescript-eslint/eslint-plugin': 5.40.0_bomoubwgcm5gub6ncofkqpat4u - '@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/eslint-plugin': 5.40.0_ryuuuwjgkm45eys4ms27dfx6xm + '@typescript-eslint/parser': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi eslint: 8.21.0 - typescript: 4.7.4 + typescript: 4.9.4 dev: true /eslint-config-xo/0.42.0_eslint@8.21.0: @@ -6294,7 +6539,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/parser': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi debug: 3.2.7 eslint: 8.21.0 eslint-import-resolver-node: 0.3.6 @@ -6343,7 +6588,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.40.0_qugx7qdu5zevzvxaiqyxfiwquq + '@typescript-eslint/parser': 5.40.0_ggwlz5rjjuds5feuls6rqqovzi array-includes: 3.1.5 array.prototype.flat: 1.3.0 debug: 2.6.9 @@ -6518,7 +6763,7 @@ packages: '@typescript-eslint/eslint-plugin': optional: true dependencies: - '@typescript-eslint/eslint-plugin': 5.40.0_bomoubwgcm5gub6ncofkqpat4u + '@typescript-eslint/eslint-plugin': 5.40.0_ryuuuwjgkm45eys4ms27dfx6xm eslint: 8.21.0 eslint-rule-composer: 0.3.0 dev: true @@ -6744,6 +6989,17 @@ packages: jest-util: 29.2.1 dev: true + /expect/29.3.1: + resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/expect-utils': 29.3.1 + jest-get-type: 29.2.0 + jest-matcher-utils: 29.3.1 + jest-message-util: 29.3.1 + jest-util: 29.3.1 + dev: true + /extend/3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: true @@ -6991,6 +7247,10 @@ packages: for-in: 1.0.2 dev: true + /form-data-encoder/2.1.3: + resolution: {integrity: sha512-KqU0nnPMgIJcCOFTNJFEA8epcseEaoox4XZffTgy8jlI6pL/5EFyR54NRG7CnCJN0biY7q52DO3MH6/sJ/TKlQ==} + engines: {node: '>= 14.17'} + /form-data/4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -7150,7 +7410,6 @@ packages: /get-stream/6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - dev: true /get-symbol-description/1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} @@ -7265,6 +7524,13 @@ packages: type-fest: 0.20.2 dev: true + /globals/13.18.0: + resolution: {integrity: sha512-/mR4KI8Ps2spmoc0Ulu9L7agOF0du1CZNQ3dke8yItYlyKNmGrkONemBbd6V8UTc1Wgcqn21t3WYB7dbRmh6/A==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + /globalthis/1.0.2: resolution: {integrity: sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==} engines: {node: '>= 0.4'} @@ -7329,9 +7595,27 @@ packages: lowercase-keys: 2.0.0 p-cancelable: 2.1.1 responselike: 2.0.0 + dev: false + + /got/12.5.3: + resolution: {integrity: sha512-8wKnb9MGU8IPGRIo+/ukTy9XLJBwDiCpIf5TVzQ9Cpol50eMTpBq2GAuDsuDIz7hTYmZgMgC1e9ydr6kSDWs3w==} + engines: {node: '>=14.16'} + dependencies: + '@sindresorhus/is': 5.3.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.2 + decompress-response: 6.0.0 + form-data-encoder: 2.1.3 + get-stream: 6.0.1 + http2-wrapper: 2.2.0 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 /graceful-fs/4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true /graceful-fs/4.2.9: resolution: {integrity: sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==} @@ -7491,8 +7775,8 @@ packages: lru-cache: 6.0.0 dev: true - /hpagent/1.0.0: - resolution: {integrity: sha512-SCleE2Uc1bM752ymxg8QXYGW0TWtAV4ZW3TqH1aOnyi6T6YW2xadCcclm5qeVjvMvfQ2RKNtZxO7uVb9CTPt1A==} + /hpagent/1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} engines: {node: '>=14'} dev: false @@ -7522,14 +7806,14 @@ packages: resolution: {integrity: sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==} dev: true - /htmlnano/2.0.0_postcss@8.4.14+svgo@2.8.0: - resolution: {integrity: sha512-thKQfhcp2xgtsWNE27A2bliEeqVL5xjAgGn0wajyttvFFsvFWWah1ntV9aEX61gz0T6MBQ5xK/1lXuEumhJTcg==} + /htmlnano/2.0.3_postcss@8.4.14+svgo@2.8.0: + resolution: {integrity: sha512-S4PGGj9RbdgW8LhbILNK7W9JhmYP8zmDY7KDV/8eCiJBQJlbmltp5I0gv8c5ntLljfdxxfmJ+UJVSqyH4mb41A==} peerDependencies: cssnano: ^5.0.11 postcss: ^8.3.11 - purgecss: ^4.0.3 + purgecss: ^5.0.0 relateurl: ^0.2.7 - srcset: ^5.0.0 + srcset: 4.0.0 svgo: ^2.8.0 terser: ^5.10.0 uncss: ^0.17.3 @@ -7551,21 +7835,21 @@ packages: uncss: optional: true dependencies: - cosmiconfig: 7.0.1 + cosmiconfig: 7.1.0 postcss: 8.4.14 posthtml: 0.16.6 svgo: 2.8.0 timsort: 0.3.0 dev: true - /htmlnano/2.0.0_postcss@8.4.6+svgo@2.8.0: - resolution: {integrity: sha512-thKQfhcp2xgtsWNE27A2bliEeqVL5xjAgGn0wajyttvFFsvFWWah1ntV9aEX61gz0T6MBQ5xK/1lXuEumhJTcg==} + /htmlnano/2.0.3_postcss@8.4.6+svgo@2.8.0: + resolution: {integrity: sha512-S4PGGj9RbdgW8LhbILNK7W9JhmYP8zmDY7KDV/8eCiJBQJlbmltp5I0gv8c5ntLljfdxxfmJ+UJVSqyH4mb41A==} peerDependencies: cssnano: ^5.0.11 postcss: ^8.3.11 - purgecss: ^4.0.3 + purgecss: ^5.0.0 relateurl: ^0.2.7 - srcset: ^5.0.0 + srcset: 4.0.0 svgo: ^2.8.0 terser: ^5.10.0 uncss: ^0.17.3 @@ -7587,7 +7871,7 @@ packages: uncss: optional: true dependencies: - cosmiconfig: 7.0.1 + cosmiconfig: 7.1.0 postcss: 8.4.6 posthtml: 0.16.6 svgo: 2.8.0 @@ -7609,6 +7893,7 @@ packages: dependencies: deep-equal: 1.0.1 http-errors: 1.8.1 + dev: false /http-cache-semantics/4.1.0: resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==} @@ -7680,6 +7965,14 @@ packages: dependencies: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + dev: false + + /http2-wrapper/2.2.0: + resolution: {integrity: sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==} + engines: {node: '>=10.19.0'} + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 /https-proxy-agent/5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} @@ -7776,8 +8069,8 @@ packages: engines: {node: '>= 4'} dev: true - /immutable/4.0.0: - resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} + /immutable/4.1.0: + resolution: {integrity: sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==} dev: true /import-fresh/3.3.0: @@ -8013,6 +8306,7 @@ packages: engines: {node: '>= 0.4'} dependencies: has-tostringtag: 1.0.0 + dev: false /is-get-set-prop/1.0.0: resolution: {integrity: sha512-DvAYZ1ZgGUz4lzxKMPYlt08qAUqyG9ckSg2pIjfvcQ7+pkVNUHk8yVLXOnCLe5WKXhLop8oorWFBJHpwWQpszQ==} @@ -8037,6 +8331,11 @@ packages: engines: {node: '>=8'} dev: false + /is-interactive/2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + dev: false + /is-js-type/2.0.0: resolution: {integrity: sha512-Aj13l47+uyTjlQNHtXBV8Cji3jb037vxwMWCgopRR8h6xocgBGW3qG8qGlIOEmbXQtkKShKuBM9e8AA1OeQ+xw==} dependencies: @@ -8175,6 +8474,11 @@ packages: engines: {node: '>=10'} dev: false + /is-unicode-supported/1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + dev: false + /is-weakref/1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: @@ -8207,7 +8511,7 @@ packages: dev: true /isarray/0.0.1: - resolution: {integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=} + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} /isarray/1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -8290,6 +8594,14 @@ packages: p-limit: 3.1.0 dev: true + /jest-changed-files/29.2.0: + resolution: {integrity: sha512-qPVmLLyBmvF5HJrY7krDisx6Voi8DmlV3GZYX0aFNbaQsZeoz1hfxcCMbqDGuQCxU1dJy9eYc2xscE8QrCCYaA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + execa: 5.1.1 + p-limit: 3.1.0 + dev: true + /jest-circus/29.1.2: resolution: {integrity: sha512-ajQOdxY6mT9GtnfJRZBRYS7toNIJayiiyjDyoZcnvPRUPwJ58JX0ci0PKAKUo2C1RyzlHw0jabjLGKksO42JGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8317,6 +8629,33 @@ packages: - supports-color dev: true + /jest-circus/29.3.1: + resolution: {integrity: sha512-wpr26sEvwb3qQQbdlmei+gzp6yoSSoSL6GsLPxnuayZSMrSd5Ka7IjAvatpIernBvT2+Ic6RLTg+jSebScmasg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.3.1 + '@jest/expect': 29.3.1 + '@jest/test-result': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + chalk: 4.1.2 + co: 4.6.0 + dedent: 0.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.3.1 + jest-matcher-utils: 29.3.1 + jest-message-util: 29.3.1 + jest-runtime: 29.3.1 + jest-snapshot: 29.3.1 + jest-util: 29.3.1 + p-limit: 3.1.0 + pretty-format: 29.3.1 + slash: 3.0.0 + stack-utils: 2.0.5 + transitivePeerDependencies: + - supports-color + dev: true + /jest-cli/29.1.2: resolution: {integrity: sha512-vsvBfQ7oS2o4MJdAH+4u9z76Vw5Q8WBQF5MchDbkylNknZdrPTX1Ix7YRJyTlOWqRaS7ue/cEAn+E4V1MWyMzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8401,6 +8740,34 @@ packages: - ts-node dev: true + /jest-cli/29.3.1_@types+node@16.11.12: + resolution: {integrity: sha512-TO/ewvwyvPOiBBuWZ0gm04z3WWP8TIK8acgPzE4IxgsLKQgb377NYGrQLc3Wl/7ndWzIH2CDNNsUjGxwLL43VQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.3.1 + '@jest/test-result': 29.3.1 + '@jest/types': 29.3.1 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.10 + import-local: 3.1.0 + jest-config: 29.3.1_@types+node@16.11.12 + jest-util: 29.3.1 + jest-validate: 29.3.1 + prompts: 2.4.2 + yargs: 17.6.0 + transitivePeerDependencies: + - '@types/node' + - supports-color + - ts-node + dev: true + /jest-config/29.1.2: resolution: {integrity: sha512-EC3Zi86HJUOz+2YWQcJYQXlf0zuBhJoeyxLM6vb6qJsVmpP7KcCP1JnyF0iaqTaXdBP8Rlwsvs7hnKWQWWLwwA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8552,7 +8919,7 @@ packages: pretty-format: 29.2.1 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1_ccwudyfw5se7hgalwgkzhn2yp4 + ts-node: 10.9.1_ace2mtubvwruu4qt46fm3vtq3a transitivePeerDependencies: - supports-color dev: true @@ -8592,7 +8959,85 @@ packages: pretty-format: 29.2.1 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1_ccwudyfw5se7hgalwgkzhn2yp4 + ts-node: 10.9.1_ace2mtubvwruu4qt46fm3vtq3a + transitivePeerDependencies: + - supports-color + dev: true + + /jest-config/29.3.1_@types+node@16.11.12: + resolution: {integrity: sha512-y0tFHdj2WnTEhxmGUK1T7fgLen7YK4RtfvpLFBXfQkh2eMJAQq24Vx9472lvn5wg0MAO6B+iPfJfzdR9hJYalg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.19.3 + '@jest/test-sequencer': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 16.11.12 + babel-jest: 29.3.1_@babel+core@7.19.3 + chalk: 4.1.2 + ci-info: 3.5.0 + deepmerge: 4.2.2 + glob: 7.2.3 + graceful-fs: 4.2.10 + jest-circus: 29.3.1 + jest-environment-node: 29.3.1 + jest-get-type: 29.2.0 + jest-regex-util: 29.2.0 + jest-resolve: 29.3.1 + jest-runner: 29.3.1 + jest-util: 29.3.1 + jest-validate: 29.3.1 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.3.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /jest-config/29.3.1_@types+node@17.0.23: + resolution: {integrity: sha512-y0tFHdj2WnTEhxmGUK1T7fgLen7YK4RtfvpLFBXfQkh2eMJAQq24Vx9472lvn5wg0MAO6B+iPfJfzdR9hJYalg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.19.3 + '@jest/test-sequencer': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + babel-jest: 29.3.1_@babel+core@7.19.3 + chalk: 4.1.2 + ci-info: 3.5.0 + deepmerge: 4.2.2 + glob: 7.2.3 + graceful-fs: 4.2.10 + jest-circus: 29.3.1 + jest-environment-node: 29.3.1 + jest-get-type: 29.2.0 + jest-regex-util: 29.2.0 + jest-resolve: 29.3.1 + jest-runner: 29.3.1 + jest-util: 29.3.1 + jest-validate: 29.3.1 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.3.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color dev: true @@ -8622,6 +9067,16 @@ packages: pretty-format: 29.1.2 dev: true + /jest-diff/29.3.1: + resolution: {integrity: sha512-vU8vyiO7568tmin2lA3r2DP8oRvzhvRcD4DjpXc6uGveQodyk7CKLhQlCSiwgx3g0pFaE88/KLZ0yaTWMc4Uiw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.3.1 + jest-get-type: 29.2.0 + pretty-format: 29.3.1 + dev: true + /jest-docblock/29.0.0: resolution: {integrity: sha512-s5Kpra/kLzbqu9dEjov30kj1n4tfu3e7Pl8v+f8jOkeWNqM6Ds8jRaJfZow3ducoQUrf2Z4rs2N5S3zXnb83gw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8629,6 +9084,13 @@ packages: detect-newline: 3.1.0 dev: true + /jest-docblock/29.2.0: + resolution: {integrity: sha512-bkxUsxTgWQGbXV5IENmfiIuqZhJcyvF7tU4zJ/7ioTutdz4ToB5Yx6JOFBpgI+TphRY4lhOyCWGNH/QFQh5T6A==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + detect-newline: 3.1.0 + dev: true + /jest-each/29.1.2: resolution: {integrity: sha512-AmTQp9b2etNeEwMyr4jc0Ql/LIX/dhbgP21gHAizya2X6rUspHn2gysMXaj6iwWuOJ2sYRgP8c1P4cXswgvS1A==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8640,6 +9102,17 @@ packages: pretty-format: 29.2.1 dev: true + /jest-each/29.3.1: + resolution: {integrity: sha512-qrZH7PmFB9rEzCSl00BWjZYuS1BSOH8lLuC0azQE9lQrAx3PWGKHTDudQiOSwIy5dGAJh7KA0ScYlCP7JxvFYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + chalk: 4.1.2 + jest-get-type: 29.2.0 + jest-util: 29.3.1 + pretty-format: 29.3.1 + dev: true + /jest-environment-jsdom/29.2.2: resolution: {integrity: sha512-5mNtTcky1+RYv9kxkwMwt7fkzyX4EJUarV7iI+NQLigpV4Hz4sgfOdP4kOpCHXbkRWErV7tgXoXLm2CKtucr+A==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8687,6 +9160,18 @@ packages: jest-util: 29.2.1 dev: true + /jest-environment-node/29.3.1: + resolution: {integrity: sha512-xm2THL18Xf5sIHoU7OThBPtuH6Lerd+Y1NLYiZJlkE3hbE+7N7r8uvHIl/FkZ5ymKXJe/11SQuf3fv4v6rUMag==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.3.1 + '@jest/fake-timers': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + jest-mock: 29.3.1 + jest-util: 29.3.1 + dev: true + /jest-environment-puppeteer/6.1.1: resolution: {integrity: sha512-Ces37g8Gdj7QaVxszeoXlvmsZxcEJN9EPUdJt8fGMLA+6ARVFKyVmFgP9xVeGyjTvzsXdtIiJdeOKMLMeD8r2A==} dependencies: @@ -8705,6 +9190,11 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /jest-get-type/29.2.0: + resolution: {integrity: sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + /jest-haste-map/29.1.2: resolution: {integrity: sha512-xSjbY8/BF11Jh3hGSPfYTa/qBFrm3TPM7WU8pU93m2gqzORVLkHFWvuZmFsTEBPRKndfewXhMOuzJNHyJIZGsw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8724,6 +9214,25 @@ packages: fsevents: 2.3.2 dev: true + /jest-haste-map/29.3.1: + resolution: {integrity: sha512-/FFtvoG1xjbbPXQLFef+WSU4yrc0fc0Dds6aRPBojUid7qlPqZvxdUBA03HW0fnVHXVCnCdkuoghYItKNzc/0A==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + '@types/graceful-fs': 4.1.5 + '@types/node': 17.0.23 + anymatch: 3.1.2 + fb-watchman: 2.0.2 + graceful-fs: 4.2.10 + jest-regex-util: 29.2.0 + jest-util: 29.3.1 + jest-worker: 29.3.1 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /jest-leak-detector/29.1.2: resolution: {integrity: sha512-TG5gAZJpgmZtjb6oWxBLf2N6CfQ73iwCe6cofu/Uqv9iiAm6g502CAnGtxQaTfpHECBdVEMRBhomSXeLnoKjiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8732,6 +9241,14 @@ packages: pretty-format: 29.2.1 dev: true + /jest-leak-detector/29.3.1: + resolution: {integrity: sha512-3DA/VVXj4zFOPagGkuqHnSQf1GZBmmlagpguxEERO6Pla2g84Q1MaVIB3YMxgUaFIaYag8ZnTyQgiZ35YEqAQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.2.0 + pretty-format: 29.3.1 + dev: true + /jest-matcher-specific-error/1.0.0: resolution: {integrity: sha512-thJdy9ibhDo8k+0arFalNCQBJ0u7eqTfpTzS2MzL3iCLmbRCkI+yhhKSiAxEi55e5ZUyf01ySa0fMqzF+sblAw==} dev: true @@ -8746,6 +9263,16 @@ packages: pretty-format: 29.1.2 dev: true + /jest-matcher-utils/29.3.1: + resolution: {integrity: sha512-fkRMZUAScup3txIKfMe3AIZZmPEjWEdsPJFK3AIy5qRohWqQFg1qrmKfYXR9qEkNc7OdAu2N4KPHibEmy4HPeQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 29.3.1 + jest-get-type: 29.2.0 + pretty-format: 29.3.1 + dev: true + /jest-message-util/27.5.1: resolution: {integrity: sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -8791,6 +9318,21 @@ packages: stack-utils: 2.0.5 dev: true + /jest-message-util/29.3.1: + resolution: {integrity: sha512-lMJTbgNcDm5z+6KDxWtqOFWlGQxD6XaYwBqHR8kmpkP+WWWG90I35kdtQHY67Ay5CSuydkTBbJG+tH9JShFCyA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.18.6 + '@jest/types': 29.3.1 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.10 + micromatch: 4.0.5 + pretty-format: 29.3.1 + slash: 3.0.0 + stack-utils: 2.0.5 + dev: true + /jest-mock/27.5.1: resolution: {integrity: sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -8808,6 +9350,15 @@ packages: jest-util: 29.2.1 dev: true + /jest-mock/29.3.1: + resolution: {integrity: sha512-H8/qFDtDVMFvFP4X8NuOT3XRDzOUTz+FeACjufHzsOIBAxivLqkB1PoLCaJx9iPPQ8dZThHPp/G3WRWyMgA3JA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + jest-util: 29.3.1 + dev: true + /jest-pnp-resolver/1.2.2_jest-resolve@29.1.2: resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==} engines: {node: '>=6'} @@ -8820,6 +9371,18 @@ packages: jest-resolve: 29.1.2 dev: true + /jest-pnp-resolver/1.2.2_jest-resolve@29.3.1: + resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + dependencies: + jest-resolve: 29.3.1 + dev: true + /jest-puppeteer/6.1.1_puppeteer@19.2.2: resolution: {integrity: sha512-cBOszleUpyipDMNYmcmH3x+687x03ZvOVz7W8X5y5TgD+j4MK+BcumwGdE1YwVS21kPLjJUu1pIdEzEDuFEBfA==} peerDependencies: @@ -8838,6 +9401,11 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /jest-regex-util/29.2.0: + resolution: {integrity: sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + /jest-resolve-dependencies/29.1.2: resolution: {integrity: sha512-44yYi+yHqNmH3OoWZvPgmeeiwKxhKV/0CfrzaKLSkZG9gT973PX8i+m8j6pDrTYhhHoiKfF3YUFg/6AeuHw4HQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8848,6 +9416,16 @@ packages: - supports-color dev: true + /jest-resolve-dependencies/29.3.1: + resolution: {integrity: sha512-Vk0cYq0byRw2WluNmNWGqPeRnZ3p3hHmjJMp2dyyZeYIfiBskwq4rpiuGFR6QGAdbj58WC7HN4hQHjf2mpvrLA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-regex-util: 29.2.0 + jest-snapshot: 29.3.1 + transitivePeerDependencies: + - supports-color + dev: true + /jest-resolve/29.1.2: resolution: {integrity: sha512-7fcOr+k7UYSVRJYhSmJHIid3AnDBcLQX3VmT9OSbPWsWz1MfT7bcoerMhADKGvKCoMpOHUQaDHtQoNp/P9JMGg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8863,6 +9441,21 @@ packages: slash: 3.0.0 dev: true + /jest-resolve/29.3.1: + resolution: {integrity: sha512-amXJgH/Ng712w3Uz5gqzFBBjxV8WFLSmNjoreBGMqxgCz5cH7swmBZzgBaCIOsvb0NbpJ0vgaSFdJqMdT+rADw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.10 + jest-haste-map: 29.3.1 + jest-pnp-resolver: 1.2.2_jest-resolve@29.3.1 + jest-util: 29.3.1 + jest-validate: 29.3.1 + resolve: 1.22.1 + resolve.exports: 1.1.0 + slash: 3.0.0 + dev: true + /jest-runner/29.1.2: resolution: {integrity: sha512-yy3LEWw8KuBCmg7sCGDIqKwJlULBuNIQa2eFSVgVASWdXbMYZ9H/X0tnXt70XFoGf92W2sOQDOIFAA6f2BG04Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8892,6 +9485,35 @@ packages: - supports-color dev: true + /jest-runner/29.3.1: + resolution: {integrity: sha512-oFvcwRNrKMtE6u9+AQPMATxFcTySyKfLhvso7Sdk/rNpbhg4g2GAGCopiInk1OP4q6gz3n6MajW4+fnHWlU3bA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/console': 29.3.1 + '@jest/environment': 29.3.1 + '@jest/test-result': 29.3.1 + '@jest/transform': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.10 + jest-docblock: 29.2.0 + jest-environment-node: 29.3.1 + jest-haste-map: 29.3.1 + jest-leak-detector: 29.3.1 + jest-message-util: 29.3.1 + jest-resolve: 29.3.1 + jest-runtime: 29.3.1 + jest-util: 29.3.1 + jest-watcher: 29.3.1 + jest-worker: 29.3.1 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + dev: true + /jest-runtime/29.1.2: resolution: {integrity: sha512-jr8VJLIf+cYc+8hbrpt412n5jX3tiXmpPSYTGnwcvNemY+EOuLNiYnHJ3Kp25rkaAcTWOEI4ZdOIQcwYcXIAZw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8922,6 +9544,36 @@ packages: - supports-color dev: true + /jest-runtime/29.3.1: + resolution: {integrity: sha512-jLzkIxIqXwBEOZx7wx9OO9sxoZmgT2NhmQKzHQm1xwR1kNW/dn0OjxR424VwHHf1SPN6Qwlb5pp1oGCeFTQ62A==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.3.1 + '@jest/fake-timers': 29.3.1 + '@jest/globals': 29.3.1 + '@jest/source-map': 29.2.0 + '@jest/test-result': 29.3.1 + '@jest/transform': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + chalk: 4.1.2 + cjs-module-lexer: 1.2.2 + collect-v8-coverage: 1.0.1 + glob: 7.2.3 + graceful-fs: 4.2.10 + jest-haste-map: 29.3.1 + jest-message-util: 29.3.1 + jest-mock: 29.3.1 + jest-regex-util: 29.2.0 + jest-resolve: 29.3.1 + jest-snapshot: 29.3.1 + jest-util: 29.3.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /jest-snapshot/29.1.2: resolution: {integrity: sha512-rYFomGpVMdBlfwTYxkUp3sjD6usptvZcONFYNqVlaz4EpHPnDvlWjvmOQ9OCSNKqYZqLM2aS3wq01tWujLg7gg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8954,6 +9606,38 @@ packages: - supports-color dev: true + /jest-snapshot/29.3.1: + resolution: {integrity: sha512-+3JOc+s28upYLI2OJM4PWRGK9AgpsMs/ekNryUV0yMBClT9B1DF2u2qay8YxcQd338PPYSFNb0lsar1B49sLDA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.19.3 + '@babel/generator': 7.19.5 + '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.19.3 + '@babel/plugin-syntax-typescript': 7.18.6_@babel+core@7.19.3 + '@babel/traverse': 7.19.4 + '@babel/types': 7.19.4 + '@jest/expect-utils': 29.3.1 + '@jest/transform': 29.3.1 + '@jest/types': 29.3.1 + '@types/babel__traverse': 7.18.2 + '@types/prettier': 2.7.1 + babel-preset-current-node-syntax: 1.0.1_@babel+core@7.19.3 + chalk: 4.1.2 + expect: 29.3.1 + graceful-fs: 4.2.10 + jest-diff: 29.3.1 + jest-get-type: 29.2.0 + jest-haste-map: 29.3.1 + jest-matcher-utils: 29.3.1 + jest-message-util: 29.3.1 + jest-util: 29.3.1 + natural-compare: 1.4.0 + pretty-format: 29.3.1 + semver: 7.3.8 + transitivePeerDependencies: + - supports-color + dev: true + /jest-transform-stub/2.0.0: resolution: {integrity: sha512-lspHaCRx/mBbnm3h4uMMS3R5aZzMwyNpNIJLXj4cEsV0mIUtS4IjYJLSoyjRCtnxb6RIGJ4NL2quZzfIeNhbkg==} dev: true @@ -8992,18 +9676,42 @@ packages: picomatch: 2.3.1 dev: true + /jest-util/29.3.1: + resolution: {integrity: sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + chalk: 4.1.2 + ci-info: 3.5.0 + graceful-fs: 4.2.10 + picomatch: 2.3.1 + dev: true + /jest-validate/29.1.2: resolution: {integrity: sha512-k71pOslNlV8fVyI+mEySy2pq9KdXdgZtm7NHrBX8LghJayc3wWZH0Yr0mtYNGaCU4F1OLPXRkwZR0dBm/ClshA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.2.1 - camelcase: 6.2.1 + camelcase: 6.3.0 chalk: 4.1.2 jest-get-type: 29.0.0 leven: 3.1.0 pretty-format: 29.2.1 dev: true + /jest-validate/29.3.1: + resolution: {integrity: sha512-N9Lr3oYR2Mpzuelp1F8negJR3YE+L1ebk1rYA5qYo9TTY3f9OWdptLoNSPP9itOCBIRBqjt/S5XHlzYglLN67g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.3.1 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.2.0 + leven: 3.1.0 + pretty-format: 29.3.1 + dev: true + /jest-watcher/29.1.2: resolution: {integrity: sha512-6JUIUKVdAvcxC6bM8/dMgqY2N4lbT+jZVsxh0hCJRbwkIEnbr/aPjMQ28fNDI5lB51Klh00MWZZeVf27KBUj5w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9018,6 +9726,20 @@ packages: string-length: 4.0.2 dev: true + /jest-watcher/29.3.1: + resolution: {integrity: sha512-RspXG2BQFDsZSRKGCT/NiNa8RkQ1iKAjrO0//soTMWx/QUt+OcxMqMSBxz23PYGqUuWm2+m2mNNsmj0eIoOaFg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/test-result': 29.3.1 + '@jest/types': 29.3.1 + '@types/node': 17.0.23 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.3.1 + string-length: 4.0.2 + dev: true + /jest-worker/29.1.2: resolution: {integrity: sha512-AdTZJxKjTSPHbXT/AIOjQVmoFx0LHFcVabWu0sxI7PAy7rFf8c0upyvgBKgguVXdM4vY74JdwkyD4hSmpTW8jA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9028,6 +9750,16 @@ packages: supports-color: 8.1.1 dev: true + /jest-worker/29.3.1: + resolution: {integrity: sha512-lY4AnnmsEWeiXirAIA0c9SDPbuCBq8IYuDVL8PMm0MZ2PEs2yPvRA/J64QBXuZp7CYKrDM/rmNrc9/i3KJQncw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 17.0.23 + jest-util: 29.3.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + /jest/29.1.2: resolution: {integrity: sha512-5wEIPpCezgORnqf+rCaYD1SK+mNN7NsstWzIsuvsnrhR/hSxXWd82oI7DkrbJ+XTD28/eG8SmxdGvukrGGK6Tw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9088,6 +9820,26 @@ packages: - ts-node dev: true + /jest/29.3.1_@types+node@16.11.12: + resolution: {integrity: sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.3.1 + '@jest/types': 29.3.1 + import-local: 3.1.0 + jest-cli: 29.3.1_@types+node@16.11.12 + transitivePeerDependencies: + - '@types/node' + - supports-color + - ts-node + dev: true + /joi/17.6.0: resolution: {integrity: sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==} dependencies: @@ -9098,17 +9850,21 @@ packages: '@sideway/pinpoint': 2.0.0 dev: true - /jose/4.10.4: - resolution: {integrity: sha512-eBH77Xs9Yc/oTDvukhAEDVMijhekPuNktXJL4tUlB22jqKP1k48v5nmsUmc8feoJPsxB3HsfEt2LbVSoz+1mng==} + /jose/4.11.0: + resolution: {integrity: sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==} - /jose/4.6.0: - resolution: {integrity: sha512-0hNAkhMBNi4soKSAX4zYOFV+aqJlEz/4j4fregvasJzEVtjDChvWqRjPvHwLqr5hx28Ayr6bsOs1Kuj87V0O8w==} - dev: false + /jose/4.11.1: + resolution: {integrity: sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==} + dev: true /js-base64/3.7.2: resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==} dev: true + /js-base64/3.7.3: + resolution: {integrity: sha512-PAr6Xg2jvd7MCR6Ld9Jg3BmTcjYsHEBx1VlwEwULb/qowPf5VD9kEMagj23Gm7JRnSvE/Da/57nChZjnvL8v6A==} + dev: true + /js-tokens/4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -9203,6 +9959,7 @@ packages: /json-stringify-safe/5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + dev: false /json5/1.0.1: resolution: {integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==} @@ -9228,7 +9985,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.9 /jsonparse/1.3.1: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} @@ -9243,16 +10000,27 @@ packages: object.assign: 4.1.4 dev: true + /just-extend/4.2.1: + resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} + dev: true + /keygrip/1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} dependencies: tsscmp: 1.0.6 + dev: false /keyv/4.0.4: resolution: {integrity: sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==} dependencies: json-buffer: 3.0.1 + dev: false + + /keyv/4.5.2: + resolution: {integrity: sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==} + dependencies: + json-buffer: 3.0.1 /kind-of/2.0.1: resolution: {integrity: sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==} @@ -9297,6 +10065,7 @@ packages: /koa-compose/4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + dev: false /koa-convert/2.0.0: resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} @@ -9304,6 +10073,7 @@ packages: dependencies: co: 4.6.0 koa-compose: 4.1.0 + dev: false /koa-logger/3.2.1: resolution: {integrity: sha512-MjlznhLLKy9+kG8nAXKJLM0/ClsQp/Or2vI3a5rbSQmgl8IJBQO0KI5FA70BvW+hqjtxjp49SpH2E7okS6NmHg==} @@ -9387,9 +10157,10 @@ packages: vary: 1.1.2 transitivePeerDependencies: - supports-color + dev: false - /ky/0.31.0: - resolution: {integrity: sha512-C27vqDb3vuxy4mi/x1wt9P1ES0QxEJg3CbPz2t3lTaQ4AvR/hkwES06yPdqol+q3hH+DrZKb/PKD+pAQkharNg==} + /ky/0.32.2: + resolution: {integrity: sha512-eBJeF6IXNwX5rksdwBrE2rIJrU2d84GoTvdM7OmmTIwUVXEMd72wIwvT+nyhrqtv7AzbSNsWz7yRsHgVhj1uog==} engines: {node: '>=14.16'} dev: true @@ -9438,6 +10209,94 @@ packages: resolution: {integrity: sha512-/wEOIONcVboFky+lWlCaF7glm1FhBz11M5PHeCApA+xDdVfmhKjHktHS8KjyGxouV5CSXIr4f3GvLSpJa4qMSg==} dev: true + /lightningcss-darwin-arm64/1.16.1: + resolution: {integrity: sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /lightningcss-darwin-x64/1.16.1: + resolution: {integrity: sha512-vyKCNPRNRqke+5i078V+N0GLfMVLEaNcqIcv28hA/vUNRGk/90EDkDB9EndGay0MoPIrC2y0qE3Y74b/OyedqQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm-gnueabihf/1.16.1: + resolution: {integrity: sha512-0AJC52l40VbrzkMJz6qRvlqVVGykkR2MgRS4bLjVC2ab0H0I/n4p6uPZXGvNIt5gw1PedeND/hq+BghNdgfuPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm64-gnu/1.16.1: + resolution: {integrity: sha512-NqxYXsRvI3/Fb9AQLXKrYsU0Q61LqKz5It+Es9gidsfcw1lamny4lmlUgO3quisivkaLCxEkogaizcU6QeZeWQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm64-musl/1.16.1: + resolution: {integrity: sha512-VUPQ4dmB9yDQxpJF8/imtwNcbIPzlL6ArLHSUInOGxipDk1lOAklhUjbKUvlL3HVlDwD3WHCxggAY01WpFcjiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-x64-gnu/1.16.1: + resolution: {integrity: sha512-A40Jjnbellnvh4YF+kt047GLnUU59iLN2LFRCyWQG+QqQZeXOCzXfTQ6EJB4yvHB1mQvWOVdAzVrtEmRw3Vh8g==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-x64-musl/1.16.1: + resolution: {integrity: sha512-VZf76GxW+8mk238tpw0u9R66gBi/m0YB0TvD54oeGiOqvTZ/mabkBkbsuXTSWcKYj8DSrLW+A42qu+6PLRsIgA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-win32-x64-msvc/1.16.1: + resolution: {integrity: sha512-Djy+UzlTtJMayVJU3eFuUW5Gdo+zVTNPJhlYw25tNC9HAoMCkIdSDDrGsWEdEyibEV7xwB8ySTmLuxilfhBtgg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /lightningcss/1.16.1: + resolution: {integrity: sha512-zU8OTaps3VAodmI2MopfqqOQQ4A9L/2Eo7xoTH/4fNkecy6ftfiGwbbRMTQqtIqJjRg3f927e+lnyBBPhucY1Q==} + engines: {node: '>= 12.0.0'} + dependencies: + detect-libc: 1.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.16.1 + lightningcss-darwin-x64: 1.16.1 + lightningcss-linux-arm-gnueabihf: 1.16.1 + lightningcss-linux-arm64-gnu: 1.16.1 + lightningcss-linux-arm64-musl: 1.16.1 + lightningcss-linux-x64-gnu: 1.16.1 + lightningcss-linux-x64-musl: 1.16.1 + lightningcss-win32-x64-msvc: 1.16.1 + dev: true + /lilconfig/2.0.5: resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==} engines: {node: '>=10'} @@ -9492,10 +10351,10 @@ packages: resolution: {integrity: sha512-V5V5Xa2Hp9i2XsbDALkBTeHXnBXh/lEmk9p22zdr7jtuOIY9TGhjK6vAvTpOOx9IKU4hJkRWZxn/HsvR1ELLtA==} requiresBuild: true dependencies: - msgpackr: 1.5.4 + msgpackr: 1.8.0 node-addon-api: 4.3.0 node-gyp-build-optional-packages: 5.0.3 - ordered-binary: 1.2.4 + ordered-binary: 1.4.0 weak-lru-cache: 1.2.2 optionalDependencies: '@lmdb/lmdb-darwin-arm64': 2.5.2 @@ -9570,10 +10429,6 @@ packages: /lodash.pick/4.4.0: resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==} - /lodash.set/4.3.2: - resolution: {integrity: sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=} - dev: true - /lodash.sortby/4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -9589,7 +10444,7 @@ packages: dev: true /lodash.uniq/4.5.0: - resolution: {integrity: sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=} + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} dev: true /lodash.zip/4.2.0: @@ -9607,6 +10462,14 @@ packages: is-unicode-supported: 0.1.0 dev: false + /log-symbols/5.1.0: + resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} + engines: {node: '>=12'} + dependencies: + chalk: 5.1.2 + is-unicode-supported: 1.3.0 + dev: false + /log-update/4.0.0: resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} engines: {node: '>=10'} @@ -9641,6 +10504,11 @@ packages: /lowercase-keys/2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} + dev: false + + /lowercase-keys/3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} /lowlight/1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} @@ -9685,7 +10553,7 @@ packages: dev: true /map-obj/1.0.1: - resolution: {integrity: sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=} + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} dev: true @@ -10224,11 +11092,16 @@ packages: /mimic-response/1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} + dev: false /mimic-response/3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + /mimic-response/4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + /min-indent/1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -10292,10 +11165,6 @@ packages: engines: {node: '>=10'} hasBin: true - /module-alias/2.2.2: - resolution: {integrity: sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==} - dev: false - /mri/1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -10312,73 +11181,26 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true - /msgpackr-extract-darwin-arm64/1.1.0: - resolution: {integrity: sha512-s1kHoT12tS2cCQOv+Wl3I+/cYNJXBPtwQqGA+dPYoXmchhXiE0Nso+BIfvQ5PxbmAyjj54Q5o7PnLTqVquNfZA==} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /msgpackr-extract-darwin-x64/1.1.0: - resolution: {integrity: sha512-yx/H/i12IKg4eWGu/eKdKzJD4jaYvvujQSaVmeOMCesbSQnWo5X6YR9TFjoiNoU9Aexk1KufzL9gW+1DozG1yw==} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /msgpackr-extract-linux-arm/1.1.0: - resolution: {integrity: sha512-0VvSCqi12xpavxl14gMrauwIzHqHbmSChUijy/uo3mpjB1Pk4vlisKpZsaOZvNJyNKj0ACi5jYtbWnnOd7hYGw==} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /msgpackr-extract-linux-arm64/1.1.0: - resolution: {integrity: sha512-AxFle3fHNwz2V4CYDIGFxI6o/ZuI0lBKg0uHI8EcCMUmDE5mVAUWYge5WXmORVvb8sVWyVgFlmi3MTu4Ve6tNQ==} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /msgpackr-extract-linux-x64/1.1.0: - resolution: {integrity: sha512-O+XoyNFWpdB8oQL6O/YyzffPpmG5rTNrr1nKLW70HD2ENJUhcITzbV7eZimHPzkn8LAGls1tBaMTHQezTBpFOw==} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /msgpackr-extract-win32-x64/1.1.0: - resolution: {integrity: sha512-6AJdM5rNsL4yrskRfhujVSPEd6IBpgvsnIT/TPowKNLQ62iIdryizPY2PJNFiW3AJcY249AHEiDBXS1cTDPxzA==} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /msgpackr-extract/1.1.4: - resolution: {integrity: sha512-WQbHvsThprXh+EqZYy+SQFEs7z6bNM7a0vgirwUfwUcphWGT2mdPcpyLCNiRsN6w5q5VKJUMblHY+tNEyceb9Q==} + /msgpackr-extract/2.2.0: + resolution: {integrity: sha512-0YcvWSv7ZOGl9Od6Y5iJ3XnPww8O7WLcpYMDwX+PAA/uXLDtyw94PJv9GLQV/nnp3cWlDhMoyKZIQLrx33sWog==} + hasBin: true requiresBuild: true dependencies: - node-gyp-build-optional-packages: 4.3.5 + node-gyp-build-optional-packages: 5.0.3 optionalDependencies: - msgpackr-extract-darwin-arm64: 1.1.0 - msgpackr-extract-darwin-x64: 1.1.0 - msgpackr-extract-linux-arm: 1.1.0 - msgpackr-extract-linux-arm64: 1.1.0 - msgpackr-extract-linux-x64: 1.1.0 - msgpackr-extract-win32-x64: 1.1.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 2.2.0 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 2.2.0 + '@msgpackr-extract/msgpackr-extract-linux-arm': 2.2.0 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 2.2.0 + '@msgpackr-extract/msgpackr-extract-linux-x64': 2.2.0 + '@msgpackr-extract/msgpackr-extract-win32-x64': 2.2.0 dev: true optional: true - /msgpackr/1.5.4: - resolution: {integrity: sha512-Z7w5Jg+2Q9z9gJxeM68d7tSuWZZGnFIRhZnyqcZCa/1dKkhOCNvR1TUV3zzJ3+vj78vlwKRzUgVDlW4jiSOeDA==} + /msgpackr/1.8.0: + resolution: {integrity: sha512-1Cos3r86XACdjLVY4CN8r72Cgs5lUzxSON6yb81sNZP9vC9nnBrEbu1/ldBhuR9BKejtoYV5C9UhmYUvZFJSNQ==} optionalDependencies: - msgpackr-extract: 1.1.4 + msgpackr-extract: 2.2.0 dev: true /multi-fork/0.0.2: @@ -10388,18 +11210,6 @@ packages: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: false - /nanoid/3.1.30: - resolution: {integrity: sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: false - - /nanoid/3.3.1: - resolution: {integrity: sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: true - /nanoid/3.3.4: resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10412,30 +11222,29 @@ packages: /negotiator/0.6.2: resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} engines: {node: '>= 0.6'} + dev: false /negotiator/0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} dev: true + /nise/5.1.3: + resolution: {integrity: sha512-U597iWTTBBYIV72986jyU382/MMZ70ApWcRmkoF1AZ75bpqOtI3Gugv/6+0jLgoDOabmcSwYBkSSAWIp1eA5cg==} + dependencies: + '@sinonjs/commons': 2.0.0 + '@sinonjs/fake-timers': 7.1.2 + '@sinonjs/text-encoding': 0.7.2 + just-extend: 4.2.1 + path-to-regexp: 1.8.0 + dev: true + /no-case/3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 tslib: 2.4.0 - /nock/13.2.2: - resolution: {integrity: sha512-PcBHuvl9i6zfaJ50A7LS55oU+nFLv8htXIhffJO+FxyfibdZ4jEvd9kTuvkrJireBFIGMZ+oUIRpMK5gU9h//g==} - engines: {node: '>= 10.13'} - dependencies: - debug: 4.3.3 - json-stringify-safe: 5.0.1 - lodash.set: 4.3.2 - propagate: 2.0.1 - transitivePeerDependencies: - - supports-color - dev: true - /node-addon-api/3.2.1: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} dev: true @@ -10456,19 +11265,13 @@ packages: whatwg-url: 5.0.0 dev: true - /node-gyp-build-optional-packages/4.3.5: - resolution: {integrity: sha512-5ke7D8SiQsTQL7CkHpfR1tLwfqtKc0KYEmlnkwd40jHCASskZeS98qoZ1qDUns2aUQWikcjidRUs6PM/3iyN/w==} - hasBin: true - dev: true - optional: true - /node-gyp-build-optional-packages/5.0.3: resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==} hasBin: true dev: true - /node-gyp-build/4.3.0: - resolution: {integrity: sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q==} + /node-gyp-build/4.5.0: + resolution: {integrity: sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==} hasBin: true dev: true @@ -10476,8 +11279,8 @@ packages: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true - /node-mocks-http/1.11.0: - resolution: {integrity: sha512-jS/WzSOcKbOeGrcgKbenZeNhxUNnP36Yw11+hL4TTxQXErGfqYZ+MaYNNvhaTiGIJlzNSqgQkk9j8dSu1YWSuw==} + /node-mocks-http/1.12.1: + resolution: {integrity: sha512-jrA7Sn3qI6GsHgWtUW3gMj0vO6Yz0nJjzg3jRZYjcfj4tzi8oWPauDK1qHVJoAxTbwuDHF1JiM9GISZ/ocI/ig==} engines: {node: '>=0.6'} dependencies: accepts: 1.3.8 @@ -10492,10 +11295,6 @@ packages: type-is: 1.6.18 dev: true - /node-releases/2.0.5: - resolution: {integrity: sha512-U9h1NLROZTq9uE1SNffn6WuPDg8icmi3ns4rEl/oTfIle4iLjTliCzgTsbaIFMq/Xn078/lfY/BL0GWZ+psK4Q==} - dev: true - /node-releases/2.0.6: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} dev: true @@ -10559,6 +11358,11 @@ packages: /normalize-url/6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + dev: false + + /normalize-url/7.2.0: + resolution: {integrity: sha512-uhXOdZry0L6M2UIo9BTt7FdpBDiAGN/7oItedQwPKh8jh31ZlvC8U9Xl/EJ3aijDHaywXTW3QbZ6LuCocur1YA==} + engines: {node: '>=12.20'} /npm-run-path/4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} @@ -10574,8 +11378,8 @@ packages: path-key: 4.0.0 dev: true - /nth-check/2.0.1: - resolution: {integrity: sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==} + /nth-check/2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: boolbase: 1.0.0 dev: true @@ -10666,7 +11470,7 @@ packages: debug: 4.3.4 ejs: 3.1.8 got: 11.8.5 - jose: 4.10.4 + jose: 4.11.0 jsesc: 3.0.2 koa: 2.13.4 koa-compose: 4.1.0 @@ -10677,7 +11481,7 @@ packages: quick-lru: 5.1.1 raw-body: 2.5.1 optionalDependencies: - paseto3: /paseto/3.1.1 + paseto3: /paseto/3.1.2 transitivePeerDependencies: - supports-color dev: false @@ -10692,6 +11496,7 @@ packages: engines: {node: '>= 0.8'} dependencies: ee-first: 1.1.1 + dev: false /once/1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -10713,6 +11518,7 @@ packages: /only/0.0.2: resolution: {integrity: sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=} + dev: false /open/8.4.0: resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} @@ -10775,8 +11581,23 @@ packages: wcwidth: 1.0.1 dev: false - /ordered-binary/1.2.4: - resolution: {integrity: sha512-A/csN0d3n+igxBPfUrjbV5GC69LWj2pjZzAAeeHXLukQ4+fytfP4T1Lg0ju7MSPSwq7KtHkGaiwO8URZN5IpLg==} + /ora/6.1.2: + resolution: {integrity: sha512-EJQ3NiP5Xo94wJXIzAyOtSb0QEIAUu7m8t6UZ9krbz0vAJqr92JpcK/lEXg91q6B9pEGqrykkd2EQplnifDSBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + bl: 5.1.0 + chalk: 5.1.2 + cli-cursor: 4.0.0 + cli-spinners: 2.6.1 + is-interactive: 2.0.0 + is-unicode-supported: 1.3.0 + log-symbols: 5.1.0 + strip-ansi: 7.0.1 + wcwidth: 1.0.1 + dev: false + + /ordered-binary/1.4.0: + resolution: {integrity: sha512-EHQ/jk4/a9hLupIKxTfUsQRej1Yd/0QLQs3vGvIqg5ZtCYSzNhkzHoZc7Zf4e4kUlDaC3Uw8Q/1opOLNN2OKRQ==} dev: true /os-homedir/1.0.2: @@ -10795,6 +11616,11 @@ packages: /p-cancelable/2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + dev: false + + /p-cancelable/3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} /p-defer/3.0.0: resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} @@ -10861,24 +11687,24 @@ packages: /packet-reader/1.0.0: resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==} - /parcel/2.7.0_postcss@8.4.14: - resolution: {integrity: sha512-pRYwnivwtNP0tip8xYSo4zCB0XhLt7/gJzP1p8OovCqkmFjG9VG+GW9TcAKqMIo0ovEa9tT+/s6gY1Qy+BONGQ==} + /parcel/2.8.0_postcss@8.4.14: + resolution: {integrity: sha512-p7Fo75CeMw5HC1luovYpBjzPbAJv/Gn7lxcs4f0LxcwBCWbkQ73zHgJXJQqnM38qQABEYEiQq6000+j+k5U/Mw==} engines: {node: '>= 12.0.0'} hasBin: true peerDependenciesMeta: '@parcel/core': optional: true dependencies: - '@parcel/config-default': 2.7.0_6sm72dhu5rdyvbpt4eu53qwhom - '@parcel/core': 2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/events': 2.7.0 - '@parcel/fs': 2.7.0_@parcel+core@2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/package-manager': 2.7.0_@parcel+core@2.7.0 - '@parcel/reporter-cli': 2.7.0_@parcel+core@2.7.0 - '@parcel/reporter-dev-server': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/config-default': 2.8.0_qtrkvsfkn7ppxb6dtfnkut4prm + '@parcel/core': 2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/events': 2.8.0 + '@parcel/fs': 2.8.0_@parcel+core@2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/package-manager': 2.8.0_@parcel+core@2.8.0 + '@parcel/reporter-cli': 2.8.0_@parcel+core@2.8.0 + '@parcel/reporter-dev-server': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 chalk: 4.1.2 commander: 7.2.0 get-port: 4.2.0 @@ -10893,24 +11719,24 @@ packages: - uncss dev: true - /parcel/2.7.0_postcss@8.4.6: - resolution: {integrity: sha512-pRYwnivwtNP0tip8xYSo4zCB0XhLt7/gJzP1p8OovCqkmFjG9VG+GW9TcAKqMIo0ovEa9tT+/s6gY1Qy+BONGQ==} + /parcel/2.8.0_postcss@8.4.6: + resolution: {integrity: sha512-p7Fo75CeMw5HC1luovYpBjzPbAJv/Gn7lxcs4f0LxcwBCWbkQ73zHgJXJQqnM38qQABEYEiQq6000+j+k5U/Mw==} engines: {node: '>= 12.0.0'} hasBin: true peerDependenciesMeta: '@parcel/core': optional: true dependencies: - '@parcel/config-default': 2.7.0_bwsugnjfjtzrnvkdck67xbzp2m - '@parcel/core': 2.7.0 - '@parcel/diagnostic': 2.7.0 - '@parcel/events': 2.7.0 - '@parcel/fs': 2.7.0_@parcel+core@2.7.0 - '@parcel/logger': 2.7.0 - '@parcel/package-manager': 2.7.0_@parcel+core@2.7.0 - '@parcel/reporter-cli': 2.7.0_@parcel+core@2.7.0 - '@parcel/reporter-dev-server': 2.7.0_@parcel+core@2.7.0 - '@parcel/utils': 2.7.0 + '@parcel/config-default': 2.8.0_c2xncdofe2kmjujtwoy3fucw4m + '@parcel/core': 2.8.0 + '@parcel/diagnostic': 2.8.0 + '@parcel/events': 2.8.0 + '@parcel/fs': 2.8.0_@parcel+core@2.8.0 + '@parcel/logger': 2.8.0 + '@parcel/package-manager': 2.8.0_@parcel+core@2.8.0 + '@parcel/reporter-cli': 2.8.0_@parcel+core@2.8.0 + '@parcel/reporter-dev-server': 2.8.0_@parcel+core@2.8.0 + '@parcel/utils': 2.8.0 chalk: 4.1.2 commander: 7.2.0 get-port: 4.2.0 @@ -10980,8 +11806,8 @@ packages: engines: {node: ^12.19.0 || >=14.15.0} dev: false - /paseto/3.1.1: - resolution: {integrity: sha512-pZmPoGPsR9dBdaKhyKeRLNdvdDbpFA/1Ku/3LuQaY/ssPE4fzSRuNz2+qoSRaIB/mDcbWyzZKjV8ook6AzlsSg==} + /paseto/3.1.2: + resolution: {integrity: sha512-TNmRcQFF7xRnkLnJ07gCD5lRB273V4fU1pCy8G2P9CQjffIzKMYa3e8yo6LlrCCjIdv4jjS85Cfz86hl5SHzng==} engines: {node: '>=16.0.0'} requiresBuild: true dev: false @@ -11023,7 +11849,6 @@ packages: resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} dependencies: isarray: 0.0.1 - dev: false /path-to-regexp/6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} @@ -11332,7 +12157,7 @@ packages: resolution: {integrity: sha512-OovjwIzs9Te46vlEx7+uXB0PLijpwjXGKXjVGGPIGubGpq7uh5Xgf6D6FiJ/SzJMBosHDp6a2hiXOS97iBXcaA==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.1 + nanoid: 3.3.4 picocolors: 1.0.0 source-map-js: 1.0.2 dev: true @@ -11474,6 +12299,15 @@ packages: react-is: 18.2.0 dev: true + /pretty-format/29.3.1: + resolution: {integrity: sha512-FyLnmb1cYJV8biEIiRyzRFvs2lry7PPIvOqKVe1GCUEYg4YGmlx1qG9EJNMxArYm7piII4qb8UV1Pncq5dxmcg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.0.0 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + /pretty-ms/6.0.1: resolution: {integrity: sha512-ke4njoVmlotekHlHyCZ3wI/c5AMT8peuHs8rKJqekj/oR5G8lND2dVpicFlUz5cbZgE290vvkMuDwfj/OcW1kw==} engines: {node: '>=10'} @@ -11523,11 +12357,6 @@ packages: react-is: 16.13.1 dev: true - /propagate/2.0.1: - resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} - engines: {node: '>= 8'} - dev: true - /property-information/5.6.0: resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} dependencies: @@ -11669,6 +12498,11 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + /quick-lru/6.1.1: + resolution: {integrity: sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q==} + engines: {node: '>=12'} + dev: true + /range-parser/1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -12115,6 +12949,10 @@ packages: prismjs: 1.27.0 dev: true + /regenerator-runtime/0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + dev: true + /regenerator-runtime/0.13.9: resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==} @@ -12300,6 +13138,13 @@ packages: resolution: {integrity: sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==} dependencies: lowercase-keys: 2.0.0 + dev: false + + /responselike/3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + dependencies: + lowercase-keys: 3.0.0 /restore-cursor/3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} @@ -12308,6 +13153,14 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 + /restore-cursor/4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: false + /retry/0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -12398,13 +13251,13 @@ packages: /safer-buffer/2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - /sass/1.49.9: - resolution: {integrity: sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==} + /sass/1.56.1: + resolution: {integrity: sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==} engines: {node: '>=12.0.0'} hasBin: true dependencies: chokidar: 3.5.3 - immutable: 4.0.0 + immutable: 4.1.0 source-map-js: 1.0.2 dev: true @@ -12445,6 +13298,7 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 + dev: true /semver/7.3.8: resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} @@ -12452,7 +13306,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /serialize-error/7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} @@ -12537,6 +13390,17 @@ packages: semver: 7.0.0 dev: true + /sinon/15.0.0: + resolution: {integrity: sha512-pV97G1GbslaSJoSdy2F2z8uh5F+uPGp3ddOzA4JsBOUBLEQRz2OAqlKGRFTSh2KiqUCmHkzyAeu7R4x1Hx0wwg==} + dependencies: + '@sinonjs/commons': 2.0.0 + '@sinonjs/fake-timers': 9.1.2 + '@sinonjs/samsam': 7.0.1 + diff: 5.1.0 + nise: 5.1.3 + supports-color: 7.2.0 + dev: true + /sisteransi/1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true @@ -12731,8 +13595,8 @@ packages: dot-case: 3.0.4 tslib: 2.4.0 - /snakecase-keys/5.1.2: - resolution: {integrity: sha512-fvtDQZqPBqYb0dEY97TGuOMbN2NJ05Tj4MaoKwjTKkmjcG6mrd58JYGr23UWZRi6Aqv49Fk4HtjTIStOQenaug==} + /snakecase-keys/5.4.4: + resolution: {integrity: sha512-YTywJG93yxwHLgrYLZjlC75moVEX04LZM4FHfihjHe1FCXm+QaLOFfSf535aXOAd0ArVQMWUAe8ZPm4VtWyXaA==} engines: {node: '>=12'} dependencies: map-obj: 4.3.0 @@ -12987,7 +13851,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: true /strip-bom/3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} @@ -13238,7 +14101,7 @@ packages: dependencies: '@trysound/sax': 0.2.0 commander: 7.2.0 - css-select: 4.2.1 + css-select: 4.3.0 css-tree: 1.1.3 csso: 4.2.0 picocolors: 1.0.0 @@ -13336,13 +14199,13 @@ packages: supports-hyperlinks: 2.3.0 dev: true - /terser/5.15.0: - resolution: {integrity: sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==} + /terser/5.15.1: + resolution: {integrity: sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==} engines: {node: '>=10'} hasBin: true dependencies: '@jridgewell/source-map': 0.3.2 - acorn: 8.8.0 + acorn: 8.8.1 commander: 2.20.3 source-map-support: 0.5.21 dev: true @@ -13474,7 +14337,7 @@ packages: dev: true /trim/0.0.1: - resolution: {integrity: sha512-YzQV+TZg4AxpKxaTHK3c3D+kRDCGVEE7LemdlQZoQXn0iennk10RsIoY6ikzAqJTc9Xjl9C1/waHom/J86ziAQ==} + resolution: {integrity: sha1-WFhUf2spB1fulczMZm+1AITEYN0=} dev: true /trough/1.0.5: @@ -13485,7 +14348,7 @@ packages: resolution: {integrity: sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==} dev: true - /ts-jest/29.0.3_37jxomqt5oevoqzq6g3r6n3ili: + /ts-jest/29.0.3_5xcodqox2j6ogkdcajmxw2vjdu: resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -13515,11 +14378,11 @@ packages: lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.3.8 - typescript: 4.7.4 + typescript: 4.9.4 yargs-parser: 21.1.1 dev: true - /ts-node/10.7.0_bjctuninx3nzqxltyvshqte2ni: + /ts-node/10.7.0_73inix45wpcdjnppmmovzbfudu: resolution: {integrity: sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==} hasBin: true peerDependencies: @@ -13545,12 +14408,12 @@ packages: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.7.4 + typescript: 4.9.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true - /ts-node/10.9.1_ccwudyfw5se7hgalwgkzhn2yp4: + /ts-node/10.9.1_ace2mtubvwruu4qt46fm3vtq3a: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -13576,7 +14439,7 @@ packages: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.7.4 + typescript: 4.9.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true @@ -13601,18 +14464,23 @@ packages: /tslib/2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + /tslib/2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + dev: true + /tsscmp/1.0.6: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + dev: false - /tsutils/3.21.0_typescript@4.7.4: + /tsutils/3.21.0_typescript@4.9.4: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 4.7.4 + typescript: 4.9.4 dev: true /tty-table/4.1.6: @@ -13680,6 +14548,11 @@ packages: engines: {node: '>=10'} dev: true + /type-fest/2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + dev: true + /type-fest/2.8.0: resolution: {integrity: sha512-O+V9pAshf9C6loGaH0idwsmugI2LxVNR7DtS40gVo2EXZVYFgz9OuNtOhgHLdHdapOEWNdvz9Ob/eeuaWwwlxA==} engines: {node: '>=12.20'} @@ -13695,8 +14568,8 @@ packages: /typedarray/0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - /typescript/4.7.4: - resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==} + /typescript/4.9.4: + resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==} engines: {node: '>=4.2.0'} hasBin: true dev: true @@ -13973,6 +14846,7 @@ packages: /vary/1.1.2: resolution: {integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=} engines: {node: '>= 0.8'} + dev: false /vfile-location/3.2.0: resolution: {integrity: sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==} @@ -14335,6 +15209,7 @@ packages: /ylru/1.2.1: resolution: {integrity: sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==} engines: {node: '>= 4.0.0'} + dev: false /yn/3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} @@ -14348,6 +15223,10 @@ packages: /zod/3.19.1: resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==} + /zod/3.20.0: + resolution: {integrity: sha512-ZWxs7oM5ixoo1BMoxTNeDMYSih/F/FUnExsnRtHT04rG6q0Bd74TKS45RGXw07TOalOZyyzdKaYH38k8yTEv9A==} + dev: true + /zwitch/1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} dev: true