diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml deleted file mode 100644 index b1033d378..000000000 --- a/.github/workflows/deploy-dev.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Deploy Dev - -on: - push: - branches: [master] - -concurrency: deploy-dev - -jobs: - deploy: - environment: dev - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 - - - name: Build - run: pnpm -- lerna run build --stream - - - name: Add official connectors - run: pnpm add-official-connectors - working-directory: packages/core - - # See warning in https://pnpm.io/cli/prune - - name: Prune - run: rm -rf node_modules packages/*/node_modules && pnpm i - env: - NODE_ENV: production - - - name: Setup env - working-directory: packages/core - run: echo "$DEV_CORE_ENV" >> .env - env: - DEV_CORE_ENV: ${{ secrets.DEV_CORE_ENV }} - - - name: Install SSH key - uses: shimataro/ssh-key-action@v2 - with: - key: ${{ secrets.DEV_SSH_KEY }} - known_hosts: ${{ secrets.DEV_SSH_KNOWN_HOSTS }} - config: ${{ secrets.DEV_SSH_CONFIG }} - - - name: Rsync folder - run: rsync --filter='exclude .git' -r -a --delete-before --ignore-times ./ $DEV_SERVER_IP:~/logto - env: - DEV_SERVER_IP: ${{ secrets.DEV_SERVER_IP }} - - - name: Sleep for 5 seconds - run: sleep 5s - - - name: Health check - run: curl $DEV_SERVER_URL/api/status -If - env: - DEV_SERVER_URL: ${{ secrets.DEV_SERVER_URL }} diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 113c2a46f..e61b976bf 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -83,13 +83,19 @@ jobs: - name: Extract run: tar -xzf logto.tar.gz - - name: Run Logto - run: node . --from-root --all-yes & + - name: Seed database working-directory: logto/packages/core + run: npm run cli db seed + env: + DB_URL: postgres://postgres:postgres@localhost:5432 + + - name: Run Logto + working-directory: logto/packages/core + run: node . --from-root --all-yes & env: INTEGRATION_TEST: true NODE_ENV: production - DB_URL_DEFAULT: postgres://postgres:postgres@localhost:5432 + DB_URL: postgres://postgres:postgres@localhost:5432 - name: Sleep for 5 seconds run: sleep 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f519bce1..39c4634d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,26 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + deploy-dev: + runs-on: ubuntu-latest + needs: dockerize + environment: dev + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + + steps: + - name: Login via Azure CLI + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Deploy to containerapp + uses: azure/CLI@v1 + with: + inlineScript: | + az config set extension.use_dynamic_install=yes_without_prompt + az containerapp update -n logto-dev -g LogtoDev --image svhd/logto:edge + + create-github-release: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') diff --git a/CHANGELOG.md b/CHANGELOG.md index 7231bc1f4..e6749bc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,71 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.0.0-beta.10](https://github.com/logto-io/logto/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2022-09-28) + + +### ⚠ BREAKING CHANGES + +* **core:** update `koaAuth()` to inject detailed auth info (#1977) +* **core:** update user scopes (#1922) + +### Features + +* **console:** auto detect language setting ([#1941](https://github.com/logto-io/logto/issues/1941)) ([49b4303](https://github.com/logto-io/logto/commit/49b430394dc961451a6abca26a95ebba8d22f68c)) +* **console:** configure M2M app access ([#1999](https://github.com/logto-io/logto/issues/1999)) ([a75f8fe](https://github.com/logto-io/logto/commit/a75f8fe959b5a0b0f670bcec83b072e4d41c7890)) +* **core,phrases:** add check protected access function ([e405ef7](https://github.com/logto-io/logto/commit/e405ef7bb8fdbf01d52ef83b19350189e32a39b6)) +* **core,schemas:** add phrases schema and GET /custom-phrases/:languageKey route ([#1905](https://github.com/logto-io/logto/issues/1905)) ([7242aa8](https://github.com/logto-io/logto/commit/7242aa8c2bbb70c51e9b00dd5e3aff595c3c2eff)) +* **core,schemas:** migration deploy cli ([#1966](https://github.com/logto-io/logto/issues/1966)) ([7cc2f4d](https://github.com/logto-io/logto/commit/7cc2f4d14219145e562cebef41ebb3963083cc89)) +* **core,schemas:** use timestamp to version migrations ([bb4bfd3](https://github.com/logto-io/logto/commit/bb4bfd3d41fdd415f68e6e13f0d4a7e8a0093933)) +* **core:** add DELETE /custom-phrases/:languageKey route ([#1919](https://github.com/logto-io/logto/issues/1919)) ([c72be69](https://github.com/logto-io/logto/commit/c72be69bea639689721651b20fd559939f6c0ce6)) +* **core:** add GET /custom-phrases route ([#1935](https://github.com/logto-io/logto/issues/1935)) ([5fe0cf4](https://github.com/logto-io/logto/commit/5fe0cf4257a72f96fc439132c7b5b58e07352aa3)) +* **core:** add POST /session/forgot-password/{email,sms}/send-passcode ([#1963](https://github.com/logto-io/logto/issues/1963)) ([af2600d](https://github.com/logto-io/logto/commit/af2600d828bf315ce57de5813168571e7042d8de)) +* **core:** add POST /session/forgot-password/{email,sms}/verify-passcode ([#1968](https://github.com/logto-io/logto/issues/1968)) ([1ea39f3](https://github.com/logto-io/logto/commit/1ea39f346367d9f300be7281a65e689bf198a65c)) +* **core:** add POST /session/forgot-password/reset ([#1972](https://github.com/logto-io/logto/issues/1972)) ([acdc86c](https://github.com/logto-io/logto/commit/acdc86c8560d30a89eccb6b0f6892221ea1bc5e0)) +* **core:** add PUT /custom-phrases/:languageKey route ([#1907](https://github.com/logto-io/logto/issues/1907)) ([0ae13f0](https://github.com/logto-io/logto/commit/0ae13f091b69c717cc17ed4f400f456f1737fc5c)) +* **core:** add ts to interaction result ([#1917](https://github.com/logto-io/logto/issues/1917)) ([e01042c](https://github.com/logto-io/logto/commit/e01042cbcd77c486afa1ee9fc2fa5c1d2df92542)) +* **core:** cannot delete custom phrase used as default language in sign-in exp ([#1951](https://github.com/logto-io/logto/issues/1951)) ([a1aef26](https://github.com/logto-io/logto/commit/a1aef26905f624569ee47e43bb3a9c9cf05b997b)) +* **core:** check migration state before app start ([#1979](https://github.com/logto-io/logto/issues/1979)) ([bf1d281](https://github.com/logto-io/logto/commit/bf1d281905bcf91a09dd8330212b6db838d65344)) +* **core:** deploy migration in transaction mode ([#1980](https://github.com/logto-io/logto/issues/1980)) ([9a89c1a](https://github.com/logto-io/logto/commit/9a89c1a200322c678e2b0246ed324c847e734fc6)) +* **core:** machine to machine apps ([cd9c697](https://github.com/logto-io/logto/commit/cd9c6978a35d9fc3a571c7bd56c972939c49a9b5)) +* **core:** save empty string as null value in DB ([#1901](https://github.com/logto-io/logto/issues/1901)) ([ecdf06e](https://github.com/logto-io/logto/commit/ecdf06ef39a177b207dc75930e96dfcf2ae12cdc)) +* **core:** support base64 format `OIDC_PRIVATE_KEYS` config in `.env` file ([#1903](https://github.com/logto-io/logto/issues/1903)) ([5bdb675](https://github.com/logto-io/logto/commit/5bdb6755d2e1bf5b6a004859561d60f1103aec69)) +* **core:** update migration state after db init ([f904b88](https://github.com/logto-io/logto/commit/f904b88f564110c1ed00b2fa1c7b3c1e168fc106)) +* **schemas:** add logto configs table ([#1940](https://github.com/logto-io/logto/issues/1940)) ([577ca48](https://github.com/logto-io/logto/commit/577ca48c072ed511550e339f2d6d1ee25cedeeac)) +* **ui:** add forget password flow ([#1952](https://github.com/logto-io/logto/issues/1952)) ([ba787b4](https://github.com/logto-io/logto/commit/ba787b434ba4dd43064c56115eabfdba9912f98a)) +* **ui:** add forget password page ([#1943](https://github.com/logto-io/logto/issues/1943)) ([39d80d9](https://github.com/logto-io/logto/commit/39d80d991235c93346c26977541d3c7040379a13)) +* **ui:** add passwordless switch ([#1976](https://github.com/logto-io/logto/issues/1976)) ([ddb0e47](https://github.com/logto-io/logto/commit/ddb0e47950b3bd7f92af2a8a5e14b201e0a10ed7)) +* **ui:** add reset password form ([#1964](https://github.com/logto-io/logto/issues/1964)) ([f97ec56](https://github.com/logto-io/logto/commit/f97ec56fbf169538cff5f8f23ed8bb67e9483b27)) +* **ui:** add reset password page ([#1961](https://github.com/logto-io/logto/issues/1961)) ([ff81b0f](https://github.com/logto-io/logto/commit/ff81b0f83e86dd3686341d3612f3f5e8f075cba6)) + + +### Bug Fixes + +* bump react sdk and essentials toolkit to support CJK characters in idToken ([2f92b43](https://github.com/logto-io/logto/commit/2f92b438644bd330fa4b8cd3698d9129ecbae282)) +* **console:** add sandbox attribute to iframe ([#1926](https://github.com/logto-io/logto/issues/1926)) ([14cb043](https://github.com/logto-io/logto/commit/14cb0439e3b7a346e6d6e1a707cdea2e7d79df52)) +* **console:** get prefixed router basename in local dev env ([ccbe5da](https://github.com/logto-io/logto/commit/ccbe5dab2d60974e9c893925d552b5fc93542490)) +* **console:** old value does not flash back on saving form ([cdbd8d7](https://github.com/logto-io/logto/commit/cdbd8d7344ad22bfc10219f732e718f437cb0668)) +* **console:** use fallback language in preview ([#1960](https://github.com/logto-io/logto/issues/1960)) ([de4c46e](https://github.com/logto-io/logto/commit/de4c46e400bb4c3f3552a984366ec99b7032ed18)) +* **core,schemas:** move alteration types into schemas src ([#2005](https://github.com/logto-io/logto/issues/2005)) ([10c1be6](https://github.com/logto-io/logto/commit/10c1be6eb76e1cb94746aee632a421aea8d4c211)) +* **core:** filter out connector-kit ([#1987](https://github.com/logto-io/logto/issues/1987)) ([f4cf89f](https://github.com/logto-io/logto/commit/f4cf89fb8deee7472d8e9bdbcb7ae7364ced1f74)) +* **phrases:** phrases-ui typo and types ([#1948](https://github.com/logto-io/logto/issues/1948)) ([2f373db](https://github.com/logto-io/logto/commit/2f373db8e43bc243973d2171867ee6e2169d280f)) +* support capital letter "Y" in command line prompt ([416f4e8](https://github.com/logto-io/logto/commit/416f4e86e390318dbb0bdb262139ca4ec72ce5fe)) +* **ui:** align mobile input outline ([#1991](https://github.com/logto-io/logto/issues/1991)) ([c9ba198](https://github.com/logto-io/logto/commit/c9ba198b59ae52d3c5b4520a98864519d7a756f7)) + + +### Reverts + +* Revert "feat(console): auto detect language setting (#1941)" (#2004) ([ad1d1e3](https://github.com/logto-io/logto/commit/ad1d1e3b592b106b3cea4703d19bab041a9d48db)), closes [#1941](https://github.com/logto-io/logto/issues/1941) [#2004](https://github.com/logto-io/logto/issues/2004) +* Revert "fix(console): use fallback language in preview (#1960)" (#2003) ([fa98452](https://github.com/logto-io/logto/commit/fa98452fe5c5e77964289df704a578e93cba877b)), closes [#1960](https://github.com/logto-io/logto/issues/1960) [#2003](https://github.com/logto-io/logto/issues/2003) + + +### Code Refactoring + +* **core:** update `koaAuth()` to inject detailed auth info ([#1977](https://github.com/logto-io/logto/issues/1977)) ([d4fc7b3](https://github.com/logto-io/logto/commit/d4fc7b3e5f4979f8419b87393bfd1af02e9a191d)) +* **core:** update user scopes ([#1922](https://github.com/logto-io/logto/issues/1922)) ([8d22b5c](https://github.com/logto-io/logto/commit/8d22b5c468e5148a3815abf93de14644cdf68e8e)) + + + ## [1.0.0-beta.9](https://github.com/logto-io/logto/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2022-09-07) diff --git a/lerna.json b/lerna.json index 5b46ae802..560a0bfde 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.0-beta.9", + "version": "1.0.0-beta.10", "npmClient": "pnpm", "useWorkspaces": true, "changelogPreset": "conventionalcommits" diff --git a/package.json b/package.json index 7a1901dce..96439c4e2 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "bootstrap": "lerna bootstrap", "prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi", "prepack": "lerna run --stream prepack", - "dev": "lerna run --stream prepack -- --incremental && lerna --ignore=@logto/integration-test run --parallel dev", + "dev": "lerna run --stream prepack -- --incremental && lerna --ignore=@logto/integration-tests run --parallel dev", "start": "cd packages/core && NODE_ENV=production node . --from-root", + "cli": "cd packages/core && logto", "alteration": "cd packages/core && pnpm alteration", "ci:build": "lerna run --stream build", "ci:lint": "lerna run --parallel lint", "ci:stylelint": "lerna run --parallel stylelint", - "ci:test": "lerna run --parallel test:coverage" + "ci:test": "lerna run --parallel test:ci" }, "devDependencies": { "@commitlint/cli": "^17.0.0", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 000000000..3e3a1fa6e --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1 @@ +alteration-scripts/ diff --git a/packages/cli/bin/logto b/packages/cli/bin/logto new file mode 100755 index 000000000..527eddf67 --- /dev/null +++ b/packages/cli/bin/logto @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib/index.js'); diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts new file mode 100644 index 000000000..0a9aa1b2e --- /dev/null +++ b/packages/cli/jest.config.ts @@ -0,0 +1 @@ +export { default } from '@silverhand/jest-config'; diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..5caa4f503 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,89 @@ +{ + "name": "@logto/cli", + "version": "1.0.0-beta.10", + "description": "Logto CLI.", + "author": "Silverhand Inc. ", + "homepage": "https://github.com/logto-io/logto#readme", + "license": "MPL-2.0", + "main": "lib/index.js", + "bin": { + "logto": "bin/logto" + }, + "files": [ + "bin", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/logto-io/logto.git" + }, + "scripts": { + "precommit": "lint-staged", + "build": "rimraf lib && tsc -p tsconfig.build.json", + "start": "node .", + "start:dev": "ts-node --files src/index.ts", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test": "jest", + "test:ci": "jest", + "prepack": "pnpm build" + }, + "engines": { + "node": "^16.0.0" + }, + "bugs": { + "url": "https://github.com/logto-io/logto/issues" + }, + "dependencies": { + "@logto/schemas": "^1.0.0-beta.10", + "@silverhand/essentials": "^1.2.1", + "chalk": "^4.1.2", + "decamelize": "^5.0.0", + "dotenv": "^16.0.0", + "find-up": "^5.0.0", + "fs-extra": "^10.1.0", + "got": "^11.8.2", + "hpagent": "^1.0.0", + "inquirer": "^8.2.2", + "nanoid": "^3.3.4", + "ora": "^5.0.0", + "roarr": "^7.11.0", + "semver": "^7.3.7", + "slonik": "^30.0.0", + "slonik-interceptor-preset": "^1.2.10", + "slonik-sql-tag-raw": "^1.1.4", + "tar": "^6.1.11", + "yargs": "^17.6.0", + "zod": "^3.18.0" + }, + "devDependencies": { + "@silverhand/eslint-config": "1.0.0", + "@silverhand/jest-config": "1.0.0", + "@silverhand/ts-config": "1.0.0", + "@types/decompress": "^4.2.4", + "@types/fs-extra": "^9.0.13", + "@types/inquirer": "^8.2.1", + "@types/jest": "^28.1.6", + "@types/node": "^16.0.0", + "@types/semver": "^7.3.12", + "@types/tar": "^6.1.2", + "@types/yargs": "^17.0.13", + "eslint": "^8.21.0", + "jest": "^28.1.3", + "lint-staged": "^13.0.0", + "prettier": "^2.7.1", + "rimraf": "^3.0.2", + "ts-node": "^10.9.1", + "typescript": "^4.7.4" + }, + "eslintConfig": { + "extends": "@silverhand", + "rules": { + "complexity": [ + "error", + 7 + ] + } + }, + "prettier": "@silverhand/eslint-config/.prettierrc" +} diff --git a/packages/cli/src/commands/database/alteration.test.ts b/packages/cli/src/commands/database/alteration.test.ts new file mode 100644 index 000000000..91f2a997e --- /dev/null +++ b/packages/cli/src/commands/database/alteration.test.ts @@ -0,0 +1,40 @@ +import { createMockPool } from 'slonik'; + +import * as queries from '../../queries/logto-config'; +import { QueryType } from '../../test-utilities'; +import * as functions from './alteration'; + +const mockQuery: jest.MockedFunction = jest.fn(); + +const pool = createMockPool({ + query: async (sql, values) => { + return mockQuery(sql, values); + }, +}); + +const files = Object.freeze([ + { filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' }, + { filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' }, + { filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' }, +]); + +describe('getUndeployedAlterations()', () => { + beforeEach(() => { + // `getAlterationFiles()` will ensure the order + jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]); + }); + + it('returns all files if database timestamp is 0', async () => { + jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0); + + await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files); + }); + + it('returns files whose timestamp is greater then database timestamp', async () => { + jest + .spyOn(queries, 'getCurrentDatabaseAlterationTimestamp') + .mockResolvedValueOnce(1_663_923_770); + + await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]); + }); +}); diff --git a/packages/cli/src/commands/database/alteration.ts b/packages/cli/src/commands/database/alteration.ts new file mode 100644 index 000000000..1b8f5bf80 --- /dev/null +++ b/packages/cli/src/commands/database/alteration.ts @@ -0,0 +1,162 @@ +import path from 'path'; + +import { AlterationScript } from '@logto/schemas/lib/types/alteration'; +import { conditional, conditionalString } from '@silverhand/essentials'; +import chalk from 'chalk'; +import findUp, { exists } from 'find-up'; +import { copy, existsSync, remove, readdir } from 'fs-extra'; +import { DatabasePool } from 'slonik'; +import { CommandModule } from 'yargs'; + +import { createPoolFromEnv } from '../../database'; +import { + getCurrentDatabaseAlterationTimestamp, + updateDatabaseTimestamp, +} from '../../queries/logto-config'; +import { getPathInModule, log } from '../../utilities'; + +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]); +}; + +const importAlterationScript = async (filePath: string): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const module = await import(filePath); + + // eslint-disable-next-line no-restricted-syntax + return module.default as AlterationScript; +}; + +type AlterationFile = { path: string; filename: string }; + +export const getAlterationFiles = async (): Promise => { + const alterationDirectory = getPathInModule('@logto/schemas', 'alterations'); + + /** + * 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 findUp( + async (directory) => { + const hasPackageJson = await exists(path.join(directory, 'package.json')); + + return conditional(hasPackageJson && directory); + }, + { + // Until we migrate to ESM + // eslint-disable-next-line unicorn/prefer-module + cwd: __dirname, + type: 'directory', + } + ); + + const 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]; + + if (!lastFile) { + return 0; + } + + return getTimestampFromFileName(lastFile.filename); +}; + +export const getUndeployedAlterations = async (pool: DatabasePool) => { + const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool); + const files = await getAlterationFiles(); + + return files.filter(({ filename }) => getTimestampFromFileName(filename) > databaseTimestamp); +}; + +const deployAlteration = async ( + pool: DatabasePool, + { path: filePath, filename }: AlterationFile +) => { + const { up } = await importAlterationScript(filePath); + + try { + await pool.transaction(async (connection) => { + await up(connection); + await updateDatabaseTimestamp(connection, getTimestampFromFileName(filename)); + }); + } catch (error: unknown) { + console.error(error); + + await pool.end(); + log.error( + `Error ocurred during running alteration ${chalk.blue(filename)}.\n\n` + + " This alteration didn't change anything since it was in a transaction.\n" + + ' Try to fix the error and deploy again.' + ); + } + + log.info(`Run alteration ${filename} succeeded`); +}; + +const alteration: CommandModule = { + command: ['alteration ', 'alt', 'alter'], + describe: 'Perform database alteration', + builder: (yargs) => + yargs.positional('action', { + describe: 'The action to perform, now it only accepts `deploy`', + type: 'string', + demandOption: true, + }), + handler: async ({ action }) => { + if (action !== 'deploy') { + log.error('Unsupported action'); + } + + const pool = await createPoolFromEnv(); + const alterations = await getUndeployedAlterations(pool); + + log.info( + `Found ${alterations.length} alteration${conditionalString( + alterations.length > 1 && 's' + )} to deploy` + ); + + // The await inside the loop is intended, alterations should run in order + for (const alteration of alterations) { + // eslint-disable-next-line no-await-in-loop + await deployAlteration(pool, alteration); + } + + await pool.end(); + }, +}; + +export default alteration; diff --git a/packages/cli/src/commands/database/config.ts b/packages/cli/src/commands/database/config.ts new file mode 100644 index 000000000..2133cace2 --- /dev/null +++ b/packages/cli/src/commands/database/config.ts @@ -0,0 +1,96 @@ +import { logtoConfigGuards, LogtoConfigKey, logtoConfigKeys } from '@logto/schemas'; +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { createPoolFromEnv } from '../../database'; +import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; +import { deduplicate, log } from '../../utilities'; + +const validKeysDisplay = chalk.green(logtoConfigKeys.join(', ')); + +type ValidateKeysFunction = { + (keys: string[]): asserts keys is LogtoConfigKey[]; + (key: string): asserts key is LogtoConfigKey; +}; + +const validateKeys: ValidateKeysFunction = (keys) => { + const invalidKey = (Array.isArray(keys) ? keys : [keys]).find( + // Using `.includes()` will result a type error + // eslint-disable-next-line unicorn/prefer-includes + (key) => !logtoConfigKeys.some((element) => element === key) + ); + + if (invalidKey) { + log.error( + `Invalid config key ${chalk.red(invalidKey)} found, expected one of ${validKeysDisplay}` + ); + } +}; + +export const getConfig: CommandModule = { + command: 'get-config [keys...]', + describe: 'Get config value(s) of the given key(s) in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('keys', { + describe: 'The additional keys to get from database', + type: 'string', + array: true, + default: [], + }), + handler: async ({ key, keys }) => { + const queryKeys = deduplicate([key, ...keys]); + validateKeys(queryKeys); + + const pool = await createPoolFromEnv(); + const { rows } = await getRowsByKeys(pool, queryKeys); + await pool.end(); + + console.log( + queryKeys + .map((currentKey) => { + const value = rows.find(({ key }) => currentKey === key)?.value; + + return ( + chalk.magenta(currentKey) + + '=' + + (value === undefined ? chalk.gray(value) : chalk.green(JSON.stringify(value))) + ); + }) + .join('\n') + ); + }, +}; + +export const setConfig: CommandModule = { + command: 'set-config ', + describe: 'Set config value of the given key in Logto database', + builder: (yargs) => + yargs + .positional('key', { + describe: `The key to get from database, one of ${validKeysDisplay}`, + type: 'string', + demandOption: true, + }) + .positional('value', { + describe: 'The value to set, should be a valid JSON string', + type: 'string', + demandOption: true, + }), + handler: async ({ key, value }) => { + validateKeys(key); + + const guarded = logtoConfigGuards[key].parse(JSON.parse(value)); + + const pool = await createPoolFromEnv(); + await updateValueByKey(pool, key, guarded); + await pool.end(); + + log.info(`Update ${chalk.green(key)} succeeded`); + }, +}; diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts new file mode 100644 index 000000000..96e8f5ba0 --- /dev/null +++ b/packages/cli/src/commands/database/index.ts @@ -0,0 +1,16 @@ +import { CommandModule } from 'yargs'; + +import { noop } from '../../utilities'; +import alteration from './alteration'; +import { getConfig, setConfig } from './config'; +import seed from './seed'; + +const database: CommandModule = { + command: ['database', 'db'], + describe: 'Commands for Logto database', + builder: (yargs) => + yargs.command(getConfig).command(setConfig).command(seed).command(alteration).demandCommand(1), + handler: noop, +}; + +export default database; diff --git a/packages/cli/src/commands/database/seed/index.ts b/packages/cli/src/commands/database/seed/index.ts new file mode 100644 index 000000000..121ddaf10 --- /dev/null +++ b/packages/cli/src/commands/database/seed/index.ts @@ -0,0 +1,159 @@ +import { readdir, readFile } from 'fs/promises'; +import path from 'path'; + +import { logtoConfigGuards, LogtoOidcConfigKey, seeds } from '@logto/schemas'; +import chalk from 'chalk'; +import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; +import { raw } from 'slonik-sql-tag-raw'; +import { CommandModule } from 'yargs'; +import { z } from 'zod'; + +import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database'; +import { + getRowsByKeys, + updateDatabaseTimestamp, + updateValueByKey, +} from '../../../queries/logto-config'; +import { buildApplicationSecret, getPathInModule, log, oraPromise } from '../../../utilities'; +import { getLatestAlterationTimestamp } from '../alteration'; +import { oidcConfigReaders } from './oidc-config'; + +const createTables = async (connection: DatabaseTransactionConnection) => { + const tableDirectory = getPathInModule('@logto/schemas', 'tables'); + const directoryFiles = await readdir(tableDirectory); + const tableFiles = directoryFiles.filter((file) => file.endsWith('.sql')); + const queries = await Promise.all( + tableFiles.map>(async (file) => [ + file, + await readFile(path.join(tableDirectory, file), 'utf8'), + ]) + ); + + // Await in loop is intended for better error handling + for (const [, query] of queries) { + // eslint-disable-next-line no-await-in-loop + await connection.query(sql`${raw(query)}`); + } +}; + +const seedTables = async (connection: DatabaseTransactionConnection) => { + const { + managementResource, + defaultSignInExperience, + createDefaultSetting, + createDemoAppApplication, + defaultRole, + } = seeds; + + await Promise.all([ + connection.query(insertInto(managementResource, 'resources')), + connection.query(insertInto(createDefaultSetting(), 'settings')), + connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), + connection.query( + insertInto(createDemoAppApplication(buildApplicationSecret()), 'applications') + ), + connection.query(insertInto(defaultRole, 'roles')), + updateDatabaseTimestamp(connection, await getLatestAlterationTimestamp()), + ]); +}; + +const seedOidcConfigs = async (pool: DatabaseTransactionConnection) => { + const configGuard = z.object({ + key: z.nativeEnum(LogtoOidcConfigKey), + value: z.unknown(), + }); + const { rows } = await getRowsByKeys(pool, Object.values(LogtoOidcConfigKey)); + // Filter out valid keys that hold a valid value + const result = await Promise.all( + rows.map>(async (row) => { + try { + const { key, value } = await configGuard.parseAsync(row); + await logtoConfigGuards[key].parseAsync(value); + + return key; + } catch {} + }) + ); + const existingKeys = new Set(result.filter(Boolean)); + + const validOptions = Object.values(LogtoOidcConfigKey).filter((key) => { + const included = existingKeys.has(key); + + if (included) { + log.info(`Key ${chalk.green(key)} exists, skipping`); + } + + return !included; + }); + + // The awaits in loop is intended since we'd like to log info in sequence + /* eslint-disable no-await-in-loop */ + for (const key of validOptions) { + const { value, fromEnv } = await oidcConfigReaders[key](); + + if (fromEnv) { + log.info(`Read config ${chalk.green(key)} from env`); + } else { + log.info(`Generated config ${chalk.green(key)}`); + } + + await updateValueByKey(pool, key, value); + } + /* eslint-enable no-await-in-loop */ + + log.succeed('Seed OIDC config'); +}; + +const seedChoices = Object.freeze(['all', 'oidc'] as const); + +type SeedChoice = typeof seedChoices[number]; + +export const seedByPool = async (pool: DatabasePool, type: SeedChoice) => { + await pool.transaction(async (connection) => { + if (type !== 'oidc') { + await oraPromise(createTables(connection), { + text: 'Create tables', + prefixText: chalk.blue('[info]'), + }); + await oraPromise(seedTables(connection), { + text: 'Seed data', + prefixText: chalk.blue('[info]'), + }); + } + + await seedOidcConfigs(connection); + }); +}; + +const seed: CommandModule, { type: string }> = { + command: 'seed [type]', + describe: 'Create database then seed tables and data', + builder: (yargs) => + yargs.positional('type', { + describe: 'Optional seed type', + type: 'string', + choices: seedChoices, + default: 'all', + }), + handler: async ({ type }) => { + const pool = await createPoolAndDatabaseIfNeeded(); + + try { + // Cannot avoid `as` since the official type definition of `yargs` doesn't work. + // The value of `type` can be ensured, so it's safe to use `as` here. + // eslint-disable-next-line no-restricted-syntax + await seedByPool(pool, type as SeedChoice); + } catch (error: unknown) { + console.error(error); + console.log(); + log.warn( + 'Error ocurred during seeding your database.\n\n' + + ' Nothing has changed since the seeding process was in a transaction.\n' + + ' Try to fix the error and seed again.' + ); + } + await pool.end(); + }, +}; + +export default seed; diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts new file mode 100644 index 000000000..5beee6f32 --- /dev/null +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -0,0 +1,90 @@ +import { generateKeyPair } from 'crypto'; +import { readFile } from 'fs/promises'; +import { promisify } from 'util'; + +import { LogtoOidcConfigKey, LogtoOidcConfigType } from '@logto/schemas'; +import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; +import { nanoid } from 'nanoid'; + +const isBase64FormatPrivateKey = (key: string) => !key.includes('-'); + +/** + * Each config reader will do the following things in order: + * 1. Try to read value from env (mimic the behavior from the original core) + * 2. Generate value if #1 doesn't work + */ +export const oidcConfigReaders: { + [key in LogtoOidcConfigKey]: () => Promise<{ + value: LogtoOidcConfigType[key]; + fromEnv: boolean; + }>; +} = { + /** + * Try to read private keys with the following order: + * + * 1. From `process.env.OIDC_PRIVATE_KEYS`. + * 2. Fetch path from `process.env.OIDC_PRIVATE_KEY_PATHS` then read from that path. + * + * + * @returns The private keys for OIDC provider. + * @throws An error when failed to read a private key. + */ + [LogtoOidcConfigKey.PrivateKeys]: async () => { + // Direct keys in env + const privateKeys = getEnvAsStringArray('OIDC_PRIVATE_KEYS'); + + if (privateKeys.length > 0) { + return { + value: privateKeys.map((key) => { + if (isBase64FormatPrivateKey(key)) { + return Buffer.from(key, 'base64').toString('utf8'); + } + + return key; + }), + fromEnv: true, + }; + } + + // Read keys from files + const privateKeyPaths = getEnvAsStringArray('OIDC_PRIVATE_KEY_PATHS'); + + if (privateKeyPaths.length > 0) { + return { + value: await Promise.all(privateKeyPaths.map(async (path) => readFile(path, 'utf8'))), + fromEnv: true, + }; + } + + // Generate a new key + const { privateKey } = await promisify(generateKeyPair)('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + return { + value: [privateKey], + fromEnv: false, + }; + }, + [LogtoOidcConfigKey.CookieKeys]: async () => { + const envKey = 'OIDC_COOKIE_KEYS'; + const keys = getEnvAsStringArray(envKey); + + return { value: keys.length > 0 ? keys : [nanoid()], fromEnv: keys.length > 0 }; + }, + [LogtoOidcConfigKey.RefreshTokenReuseInterval]: async () => { + const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL'; + const raw = Number(getEnv(envKey)); + const value = Math.max(3, raw || 0); + + return { value, fromEnv: raw === value }; + }, +}; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 000000000..050f6fd70 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,188 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdir } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { conditional } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { remove, writeFile } from 'fs-extra'; +import inquirer from 'inquirer'; +import * as semver from 'semver'; +import tar from 'tar'; +import { CommandModule } from 'yargs'; + +import { createPoolAndDatabaseIfNeeded, getDatabaseUrlFromEnv } from '../database'; +import { downloadFile, log, oraPromise, safeExecSync } from '../utilities'; +import { seedByPool } from './database/seed'; + +export type InstallArgs = { + path?: string; + silent?: boolean; +}; + +const defaultPath = path.join(os.homedir(), 'logto'); +const pgRequired = new semver.SemVer('14.0.0'); + +const validateNodeVersion = () => { + const required = new semver.SemVer('16.0.0'); + const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' })); + + if (required.compare(current) > 0) { + log.error(`Logto requires NodeJS >=${required.version}, but ${current.version} found.`); + } + + if (current.major > required.major) { + log.warn( + `Logto is tested under NodeJS ^${required.version}, but version ${current.version} found.` + ); + } +}; + +const inquireInstancePath = async (initialPath?: string) => { + const { instancePath } = await inquirer.prompt<{ instancePath: string }>( + { + name: 'instancePath', + message: 'Where should we create your Logto instance?', + type: 'input', + default: defaultPath, + filter: (value: string) => value.trim(), + validate: (value: string) => + existsSync(path.resolve(value)) + ? `The path ${chalk.green(value)} already exists, please try another.` + : true, + }, + { instancePath: initialPath } + ); + + return instancePath; +}; + +const validateDatabase = async () => { + const { hasPostgresUrl } = await inquirer.prompt<{ hasPostgresUrl?: boolean }>({ + name: 'hasPostgresUrl', + message: `Logto requires PostgreSQL >=${pgRequired.version} but cannot find in the current environment.\n Do you have a remote PostgreSQL instance ready?`, + type: 'confirm', + when: () => { + const pgOutput = safeExecSync('postgres --version') ?? ''; + // Filter out all brackets in the output since Homebrew will append `(Homebrew)`. + const pgArray = pgOutput.split(' ').filter((value) => !value.startsWith('(')); + const pgCurrent = semver.coerce(pgArray[pgArray.length - 1]); + + return !pgCurrent || pgCurrent.compare(pgRequired) < 0; + }, + }); + + if (hasPostgresUrl === false) { + log.error('Logto requires a Postgres instance to run.'); + } +}; + +const downloadRelease = async () => { + const tarFilePath = path.resolve(os.tmpdir(), './logto.tar.gz'); + + log.info(`Download Logto to ${tarFilePath}`); + await downloadFile( + 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', + tarFilePath + ); + + return tarFilePath; +}; + +const decompress = async (toPath: string, tarPath: string) => { + try { + await mkdir(toPath); + await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); + } catch (error: unknown) { + log.error(error); + } +}; + +const installLogto = async ({ path: pathArgument = defaultPath, silent = false }: InstallArgs) => { + validateNodeVersion(); + + // Get instance path + const instancePath = await inquireInstancePath(conditional(silent && pathArgument)); + + // Validate database URL + await validateDatabase(); + + // Download and decompress + const tarPath = await downloadRelease(); + await oraPromise( + decompress(instancePath, tarPath), + { + text: `Decompress to ${instancePath}`, + prefixText: chalk.blue('[info]'), + }, + true + ); + + try { + // Seed database + const pool = await createPoolAndDatabaseIfNeeded(); // It will ask for database URL and save to config + await seedByPool(pool, 'all'); + await pool.end(); + } catch (error: unknown) { + console.error(error); + + const { value } = await inquirer.prompt<{ value: boolean }>({ + name: 'value', + type: 'confirm', + message: + 'Error occurred during seeding your Logto database. Nothing has changed since the seeding process was in a transaction.\n' + + ' Would you like to continue without seed?', + default: false, + }); + + if (!value) { + await oraPromise(remove(instancePath), { + text: 'Clean up', + prefixText: chalk.blue('[info]'), + }); + + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + + log.info(`You can use ${chalk.green('db seed')} command to seed when ready.`); + } + + // Save to dot env + const databaseUrl = await getDatabaseUrlFromEnv(); + const dotEnvPath = path.resolve(instancePath, '.env'); + await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, { + encoding: 'utf8', + }); + log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`); + + // Finale + const startCommand = `cd ${instancePath} && npm start`; + log.info( + `Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}` + ); +}; + +const install: CommandModule = { + command: ['init', 'i', 'install'], + describe: 'Download and run the latest Logto release', + builder: (yargs) => + yargs.options({ + path: { + alias: 'p', + describe: 'Path of Logto, must be a non-existing path', + type: 'string', + }, + silent: { + alias: 's', + describe: 'Entering non-interactive mode', + type: 'boolean', + }, + }), + handler: async ({ path, silent }) => { + await installLogto({ path, silent }); + }, +}; + +export default install; diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts new file mode 100644 index 000000000..f9bdf8ed7 --- /dev/null +++ b/packages/cli/src/database.ts @@ -0,0 +1,140 @@ +import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas'; +import decamelize from 'decamelize'; +import { createPool, IdentifierSqlToken, parseDsn, sql, SqlToken, stringifyDsn } from 'slonik'; +import { createInterceptors } from 'slonik-interceptor-preset'; +import { z } from 'zod'; + +import { getCliConfig, log } from './utilities'; + +export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto'; + +export const getDatabaseUrlFromEnv = async () => + (await getCliConfig({ + key: 'DB_URL', + readableKey: 'Logto database URL', + defaultValue: defaultDatabaseUrl, + })) ?? ''; + +export const createPoolFromEnv = async () => { + const databaseUrl = await getDatabaseUrlFromEnv(); + + return createPool(databaseUrl, { + interceptors: createInterceptors(), + }); +}; + +/** + * Create a database pool with the database URL in config. + * If the given database does not exists, it will try to create a new database by connecting to the maintenance database `postgres`. + * + * @returns A new database pool with the database URL in config. + */ +export const createPoolAndDatabaseIfNeeded = async () => { + try { + return await createPoolFromEnv(); + } catch (error: unknown) { + const result = z.object({ code: z.string() }).safeParse(error); + + // Database does not exist, try to create one + // https://www.postgresql.org/docs/14/errcodes-appendix.html + if (!(result.success && result.data.code === '3D000')) { + log.error(error); + } + + const databaseUrl = await getDatabaseUrlFromEnv(); + const dsn = parseDsn(databaseUrl); + // It's ok to fall back to '?' since: + // - Database name is required to connect in the previous pool + // - It will throw error when creating database using '?' + const databaseName = dsn.databaseName ?? '?'; + const maintenancePool = await createPool(stringifyDsn({ ...dsn, databaseName: 'postgres' })); + await maintenancePool.query(sql` + create database ${sql.identifier([databaseName])} + with + encoding = 'UTF8' + connection_limit = -1; + `); + await maintenancePool.end(); + + log.succeed(`Created database ${databaseName}`); + + return createPoolFromEnv(); + } +}; + +// TODO: Move database utils to `core-kit` +export type Table = { table: string; fields: Record }; +export type FieldIdentifiers = { + [key in Key]: IdentifierSqlToken; +}; + +export const convertToIdentifiers = ({ table, fields }: T, withPrefix = false) => { + const fieldsIdentifiers = Object.entries(fields).map< + [keyof T['fields'], IdentifierSqlToken] + >(([key, value]) => [key, sql.identifier(withPrefix ? [table, value] : [value])]); + + return { + table: sql.identifier([table]), + // Key value inferred from the original fields directly + // eslint-disable-next-line no-restricted-syntax + fields: Object.fromEntries(fieldsIdentifiers) as FieldIdentifiers, + }; +}; + +/** + * Note `undefined` is removed from the acceptable list, + * since you should NOT call this function if ignoring the field is the desired behavior. + * Calling this function with `null` means an explicit `null` setting in database is expected. + * @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number; + * @param value The value to convert. + * @returns A primitive that can be saved into database. + */ +// eslint-disable-next-line complexity +export const convertToPrimitiveOrSql = ( + key: string, + // eslint-disable-next-line @typescript-eslint/ban-types + value: NonNullable | null + // eslint-disable-next-line @typescript-eslint/ban-types +): NonNullable | SqlToken | null => { + if (value === null) { + return null; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') { + return sql`to_timestamp(${value}::double precision / 1000)`; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + if (value === '') { + return null; + } + + return value; + } + + throw new Error(`Cannot convert ${key} to primitive`); +}; + +export const insertInto = (object: T, table: string) => { + const keys = Object.keys(object); + + return sql` + insert into ${sql.identifier([table])} + (${sql.join( + keys.map((key) => sql.identifier([decamelize(key)])), + sql`, ` + )}) + values (${sql.join( + keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), + sql`, ` + )}) + `; +}; diff --git a/packages/cli/src/include.d/slonik-interceptor-preset.d.ts b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts new file mode 100644 index 000000000..5e24372aa --- /dev/null +++ b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts @@ -0,0 +1,10 @@ +declare module 'slonik-interceptor-preset' { + import { Interceptor } from 'slonik'; + + export const createInterceptors: (config?: { + benchmarkQueries: boolean; + logQueries: boolean; + normaliseQueries: boolean; + transformFieldNames: boolean; + }) => readonly Interceptor[]; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..c8d57814d --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,25 @@ +import dotenv from 'dotenv'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import database from './commands/database'; +import install from './commands/install'; + +void yargs(hideBin(process.argv)) + .option('env', { + alias: ['e', 'env-file'], + describe: 'The path to your `.env` file', + type: 'string', + }) + .middleware(({ env }) => { + dotenv.config({ path: env }); + }) + .command(install) + .command(database) + .demandCommand(1) + .showHelpOnFail(false) + .strict() + .parserConfiguration({ + 'dot-notation': false, + }) + .parse(); diff --git a/packages/cli/src/queries/logto-config.test.ts b/packages/cli/src/queries/logto-config.test.ts new file mode 100644 index 000000000..28f4f9dbe --- /dev/null +++ b/packages/cli/src/queries/logto-config.test.ts @@ -0,0 +1,102 @@ +import { AlterationStateKey, LogtoConfigs } from '@logto/schemas'; +import { createMockPool, createMockQueryResult, sql } from 'slonik'; + +import { convertToIdentifiers } from '../database'; +import { expectSqlAssert, QueryType } from '../test-utilities'; +import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config'; + +const mockQuery: jest.MockedFunction = jest.fn(); + +const pool = createMockPool({ + query: async (sql, values) => { + return mockQuery(sql, values); + }, +}); +const { table, fields } = convertToIdentifiers(LogtoConfigs); +const timestamp = 1_663_923_776; + +describe('getCurrentDatabaseAlterationTimestamp()', () => { + it('returns 0 if query failed (table not found)', async () => { + mockQuery.mockRejectedValueOnce({ code: '42P01' }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0); + }); + + it('returns 0 if the row is not found', async () => { + const expectSql = sql` + select * from ${table} where ${fields.key}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([AlterationStateKey.AlterationState]); + + return createMockQueryResult([]); + }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0); + }); + + it('returns 0 if the value is in bad format', async () => { + const expectSql = sql` + select * from ${table} where ${fields.key}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([AlterationStateKey.AlterationState]); + + return createMockQueryResult([{ value: 'some_value' }]); + }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0); + }); + + it('returns the timestamp from database', async () => { + const expectSql = sql` + select * from ${table} where ${fields.key}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([AlterationStateKey.AlterationState]); + + // @ts-expect-error createMockQueryResult doesn't support jsonb + return createMockQueryResult([{ value: { timestamp, updatedAt: 'now' } }]); + }); + + await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toEqual(timestamp); + }); +}); + +describe('updateDatabaseTimestamp()', () => { + const expectSql = sql` + insert into ${table} (${fields.key}, ${fields.value}) + values ($1, $2::jsonb) + on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} + `; + const updatedAt = '2022-09-21T06:32:46.583Z'; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(updatedAt)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('sends upsert sql with timestamp and updatedAt', async () => { + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([ + AlterationStateKey.AlterationState, + JSON.stringify({ timestamp, updatedAt }), + ]); + + return createMockQueryResult([]); + }); + + await updateDatabaseTimestamp(pool, timestamp); + }); +}); diff --git a/packages/cli/src/queries/logto-config.ts b/packages/cli/src/queries/logto-config.ts new file mode 100644 index 000000000..b02cbbaa9 --- /dev/null +++ b/packages/cli/src/queries/logto-config.ts @@ -0,0 +1,69 @@ +import { + AlterationState, + LogtoConfig, + logtoConfigGuards, + LogtoConfigKey, + LogtoConfigs, + AlterationStateKey, +} from '@logto/schemas'; +import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; +import { z } from 'zod'; + +import { convertToIdentifiers } from '../database'; + +const { table, fields } = convertToIdentifiers(LogtoConfigs); + +export const getRowsByKeys = async ( + pool: DatabasePool | DatabaseTransactionConnection, + keys: LogtoConfigKey[] +) => + pool.query(sql` + select ${sql.join([fields.key, fields.value], sql`,`)} from ${table} + where ${fields.key} in (${sql.join(keys, sql`,`)}) + `); + +export const updateValueByKey = async ( + pool: DatabasePool | DatabaseTransactionConnection, + key: T, + value: z.infer +) => + pool.query( + sql` + insert into ${table} (${fields.key}, ${fields.value}) + values (${key}, ${sql.jsonb(value)}) + on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value} + ` + ); + +export const getCurrentDatabaseAlterationTimestamp = async (pool: DatabasePool) => { + try { + const result = await pool.maybeOne( + sql`select * from ${table} where ${fields.key}=${AlterationStateKey.AlterationState}` + ); + const parsed = logtoConfigGuards[AlterationStateKey.AlterationState].safeParse(result?.value); + + return (parsed.success && parsed.data.timestamp) || 0; + } catch (error: unknown) { + const result = z.object({ code: z.string() }).safeParse(error); + + // Relation does not exist, treat as 0 + // https://www.postgresql.org/docs/14/errcodes-appendix.html + if (result.success && result.data.code === '42P01') { + return 0; + } + + throw error; + } +}; + +export const updateDatabaseTimestamp = async ( + connection: DatabaseTransactionConnection, + timestamp: number +) => { + const value: AlterationState = { + timestamp, + updatedAt: new Date().toISOString(), + }; + + return updateValueByKey(connection, AlterationStateKey.AlterationState, value); +}; diff --git a/packages/cli/src/test-utilities.ts b/packages/cli/src/test-utilities.ts new file mode 100644 index 000000000..e03d395e8 --- /dev/null +++ b/packages/cli/src/test-utilities.ts @@ -0,0 +1,26 @@ +// Copied from core + +import { QueryResult, QueryResultRow } from 'slonik'; +import { PrimitiveValueExpression } from 'slonik/dist/src/types.d'; + +export type QueryType = ( + sql: string, + values: readonly PrimitiveValueExpression[] +) => Promise>; + +/** + * Slonik Query Mock Utils + **/ +export const expectSqlAssert = (sql: string, expectSql: string) => { + expect( + sql + .split('\n') + .map((row) => row.trim()) + .filter(Boolean) + ).toEqual( + expectSql + .split('\n') + .map((row) => row.trim()) + .filter(Boolean) + ); +}; diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts new file mode 100644 index 000000000..2f03ceaf9 --- /dev/null +++ b/packages/cli/src/utilities.ts @@ -0,0 +1,167 @@ +import { execSync } from 'child_process'; +import { createWriteStream } from 'fs'; +import path from 'path'; + +import { conditionalString, Optional } from '@silverhand/essentials'; +import chalk from 'chalk'; +import got, { Progress } from 'got'; +import { HttpsProxyAgent } from 'hpagent'; +import inquirer from 'inquirer'; +import { customAlphabet } from 'nanoid'; +import ora from 'ora'; + +export const safeExecSync = (command: string) => { + try { + return execSync(command, { encoding: 'utf8', stdio: 'pipe' }); + } catch {} +}; + +type Log = Readonly<{ + info: typeof console.log; + succeed: typeof console.log; + warn: typeof console.log; + error: (...args: Parameters) => never; +}>; + +export const log: Log = Object.freeze({ + info: (...args) => { + console.log(chalk.blue('[info]'), ...args); + }, + succeed: (...args) => { + log.info(chalk.green('✔'), ...args); + }, + warn: (...args) => { + console.warn(chalk.yellow('[warn]'), ...args); + }, + error: (...args) => { + console.error(chalk.red('[error]'), ...args); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }, +}); + +export const downloadFile = async (url: string, destination: string) => { + const { HTTPS_PROXY, HTTP_PROXY, https_proxy, http_proxy } = process.env; + const file = createWriteStream(destination); + const proxy = HTTPS_PROXY ?? https_proxy ?? HTTP_PROXY ?? http_proxy; + const stream = got.stream(url, { + ...(proxy && { agent: { https: new HttpsProxyAgent({ proxy }) } }), + }); + const spinner = ora({ + text: 'Connecting', + prefixText: chalk.blue('[info]'), + }).start(); + + stream.pipe(file); + + return new Promise((resolve, reject) => { + stream.on('downloadProgress', ({ total, percent }: Progress) => { + if (!total) { + return; + } + + // eslint-disable-next-line @silverhand/fp/no-mutation + spinner.text = `${(percent * 100).toFixed(1)}%`; + }); + + file.on('error', (error) => { + spinner.fail(); + reject(error.message); + }); + + file.on('finish', () => { + file.close(); + spinner.succeed(); + resolve(file); + }); + }); +}; + +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`)), + relativePath + ); + +export const oraPromise = async ( + promise: PromiseLike, + options?: ora.Options, + exitOnError = false +) => { + const spinner = ora(options).start(); + + try { + const result = await promise; + spinner.succeed(); + + return result; + } catch (error: unknown) { + spinner.fail(); + + if (exitOnError) { + log.error(error); + } + + throw error; + } +}; + +const cliConfig = new Map>(); + +export type GetCliConfig = { + key: string; + readableKey: string; + comments?: string; + defaultValue?: string; +}; + +export const getCliConfig = async ({ key, readableKey, comments, defaultValue }: GetCliConfig) => { + if (cliConfig.has(key)) { + return cliConfig.get(key); + } + + const { [key]: value } = process.env; + + if (!value) { + const { input } = await inquirer + .prompt<{ input?: string }>({ + type: 'input', + name: 'input', + message: `Enter your ${readableKey}${conditionalString(comments && ' ' + comments)}`, + default: defaultValue, + }) + .catch(async (error) => { + if (error.isTtyError) { + log.error(`No ${readableKey} (${chalk.green(key)}) configured in env`); + } + + // The type definition does not give us type except `any`, throw it directly will honor the original behavior. + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw error; + }); + + cliConfig.set(key, input); + + return input; + } + + cliConfig.set(key, value); + + return value; +}; + +// TODO: Move to `@silverhand/essentials` +// Intended +// eslint-disable-next-line @typescript-eslint/no-empty-function +export const noop = () => {}; + +export const deduplicate = (array: T[]) => [...new Set(array)]; + +export const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +export const buildIdGenerator = (size: number) => customAlphabet(alphabet, size); + +export const buildApplicationSecret = buildIdGenerator(21); diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 000000000..b2142cfd9 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "include": ["src"], +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..3d69fefb3 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@silverhand/ts-config/tsconfig.base", + "compilerOptions": { + "outDir": "lib", + "declaration": true, + "module": "node16", + "target": "es2022" + }, + "include": [ + "src", + "jest.config.ts" + ], + "exclude": ["**/alteration-scripts"] +} diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json new file mode 100644 index 000000000..c68416b04 --- /dev/null +++ b/packages/cli/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "allowJs": true + } +} diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index be0db52a9..98f3a636e 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -3,6 +3,41 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.0.0-beta.10](https://github.com/logto-io/logto/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2022-09-28) + + +### ⚠ BREAKING CHANGES + +* **core:** update user scopes (#1922) + +### Features + +* **console:** auto detect language setting ([#1941](https://github.com/logto-io/logto/issues/1941)) ([49b4303](https://github.com/logto-io/logto/commit/49b430394dc961451a6abca26a95ebba8d22f68c)) +* **console:** configure M2M app access ([#1999](https://github.com/logto-io/logto/issues/1999)) ([a75f8fe](https://github.com/logto-io/logto/commit/a75f8fe959b5a0b0f670bcec83b072e4d41c7890)) +* **core:** machine to machine apps ([cd9c697](https://github.com/logto-io/logto/commit/cd9c6978a35d9fc3a571c7bd56c972939c49a9b5)) + + +### Bug Fixes + +* bump react sdk and essentials toolkit to support CJK characters in idToken ([2f92b43](https://github.com/logto-io/logto/commit/2f92b438644bd330fa4b8cd3698d9129ecbae282)) +* **console:** add sandbox attribute to iframe ([#1926](https://github.com/logto-io/logto/issues/1926)) ([14cb043](https://github.com/logto-io/logto/commit/14cb0439e3b7a346e6d6e1a707cdea2e7d79df52)) +* **console:** get prefixed router basename in local dev env ([ccbe5da](https://github.com/logto-io/logto/commit/ccbe5dab2d60974e9c893925d552b5fc93542490)) +* **console:** old value does not flash back on saving form ([cdbd8d7](https://github.com/logto-io/logto/commit/cdbd8d7344ad22bfc10219f732e718f437cb0668)) +* **console:** use fallback language in preview ([#1960](https://github.com/logto-io/logto/issues/1960)) ([de4c46e](https://github.com/logto-io/logto/commit/de4c46e400bb4c3f3552a984366ec99b7032ed18)) + + +### Reverts + +* Revert "feat(console): auto detect language setting (#1941)" (#2004) ([ad1d1e3](https://github.com/logto-io/logto/commit/ad1d1e3b592b106b3cea4703d19bab041a9d48db)), closes [#1941](https://github.com/logto-io/logto/issues/1941) [#2004](https://github.com/logto-io/logto/issues/2004) +* Revert "fix(console): use fallback language in preview (#1960)" (#2003) ([fa98452](https://github.com/logto-io/logto/commit/fa98452fe5c5e77964289df704a578e93cba877b)), closes [#1960](https://github.com/logto-io/logto/issues/1960) [#2003](https://github.com/logto-io/logto/issues/2003) + + +### Code Refactoring + +* **core:** update user scopes ([#1922](https://github.com/logto-io/logto/issues/1922)) ([8d22b5c](https://github.com/logto-io/logto/commit/8d22b5c468e5148a3815abf93de14644cdf68e8e)) + + + ## [1.0.0-beta.9](https://github.com/logto-io/logto/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2022-09-07) diff --git a/packages/console/package.json b/packages/console/package.json index 08695cbde..eb828fc6b 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@logto/console", - "version": "1.0.0-beta.9", + "version": "1.0.0-beta.10", "description": "> TODO: description", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", @@ -20,17 +20,17 @@ "@fontsource/roboto-mono": "^4.5.7", "@logto/core-kit": "1.0.0-beta.16", "@logto/language-kit": "1.0.0-beta.16", - "@logto/phrases": "^1.0.0-beta.9", - "@logto/phrases-ui": "^1.0.0-beta.9", + "@logto/phrases": "^1.0.0-beta.10", + "@logto/phrases-ui": "^1.0.0-beta.10", "@logto/react": "1.0.0-beta.8", - "@logto/schemas": "^1.0.0-beta.9", + "@logto/schemas": "^1.0.0-beta.10", "@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", "@silverhand/eslint-config": "1.0.0", - "@silverhand/eslint-config-react": "1.0.0", + "@silverhand/eslint-config-react": "1.1.0", "@silverhand/essentials": "^1.2.1", "@silverhand/ts-config": "1.0.0", "@silverhand/ts-config-react": "1.0.0", diff --git a/packages/console/src/assets/images/check-demo-dark.svg b/packages/console/src/assets/images/check-demo-dark.svg index 4a94f08bd..074d3be73 100644 --- a/packages/console/src/assets/images/check-demo-dark.svg +++ b/packages/console/src/assets/images/check-demo-dark.svg @@ -1,18 +1,18 @@ - - + + - - - + + + - - - + + + - - - + + + diff --git a/packages/console/src/assets/images/check-demo.svg b/packages/console/src/assets/images/check-demo.svg index afcb36ba0..31c6fbb1f 100644 --- a/packages/console/src/assets/images/check-demo.svg +++ b/packages/console/src/assets/images/check-demo.svg @@ -1,18 +1,18 @@ - - - - - - + + + + + + - - - + + + - - - + + + diff --git a/packages/console/src/assets/images/create-app-dark.svg b/packages/console/src/assets/images/create-app-dark.svg index 4cc1e23f2..65f78c8e7 100644 --- a/packages/console/src/assets/images/create-app-dark.svg +++ b/packages/console/src/assets/images/create-app-dark.svg @@ -1,18 +1,17 @@ - - + + - - - - + + + + - - - - + + + diff --git a/packages/console/src/assets/images/create-app.svg b/packages/console/src/assets/images/create-app.svg index 287c529b3..521683ec5 100644 --- a/packages/console/src/assets/images/create-app.svg +++ b/packages/console/src/assets/images/create-app.svg @@ -1,18 +1,26 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - + + + diff --git a/packages/console/src/assets/images/customize-dark.svg b/packages/console/src/assets/images/customize-dark.svg index 0368b9ec0..a7f445dd4 100644 --- a/packages/console/src/assets/images/customize-dark.svg +++ b/packages/console/src/assets/images/customize-dark.svg @@ -1,23 +1,23 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + diff --git a/packages/console/src/assets/images/customize.svg b/packages/console/src/assets/images/customize.svg index 1ba5f81eb..08c6e6d8f 100644 --- a/packages/console/src/assets/images/customize.svg +++ b/packages/console/src/assets/images/customize.svg @@ -1,44 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - + + - - - + + + - + - - - diff --git a/packages/console/src/assets/images/further-readings-dark.svg b/packages/console/src/assets/images/further-readings-dark.svg index ee429e0f0..f6bad1545 100644 --- a/packages/console/src/assets/images/further-readings-dark.svg +++ b/packages/console/src/assets/images/further-readings-dark.svg @@ -1,21 +1,20 @@ - - - - - - - - - + + + + + + + + + + + + - - - - - - - + + + diff --git a/packages/console/src/assets/images/further-readings.svg b/packages/console/src/assets/images/further-readings.svg index 1f10daddd..aa3a197e8 100644 --- a/packages/console/src/assets/images/further-readings.svg +++ b/packages/console/src/assets/images/further-readings.svg @@ -1,33 +1,20 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - + + + diff --git a/packages/console/src/assets/images/one-click-dark.svg b/packages/console/src/assets/images/one-click-dark.svg deleted file mode 100644 index ad65ac764..000000000 --- a/packages/console/src/assets/images/one-click-dark.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/packages/console/src/assets/images/one-click.svg b/packages/console/src/assets/images/one-click.svg deleted file mode 100644 index bd9b1a311..000000000 --- a/packages/console/src/assets/images/one-click.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/packages/console/src/assets/images/passwordless-dark.svg b/packages/console/src/assets/images/passwordless-dark.svg index 13c19801a..6cb0d5c93 100644 --- a/packages/console/src/assets/images/passwordless-dark.svg +++ b/packages/console/src/assets/images/passwordless-dark.svg @@ -1,21 +1,21 @@ - - - - - - - - - - + + + + + + + + + - - - + + + + + + + - - - diff --git a/packages/console/src/assets/images/passwordless.svg b/packages/console/src/assets/images/passwordless.svg index 355d14aac..3481dea68 100644 --- a/packages/console/src/assets/images/passwordless.svg +++ b/packages/console/src/assets/images/passwordless.svg @@ -1,21 +1,23 @@ - - - - - - - - - - + + + + + + + + + + + - - - + + + + + + + - - - diff --git a/packages/console/src/assets/images/social-dark.svg b/packages/console/src/assets/images/social-dark.svg new file mode 100644 index 000000000..8bb54f1a1 --- /dev/null +++ b/packages/console/src/assets/images/social-dark.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/assets/images/social.svg b/packages/console/src/assets/images/social.svg new file mode 100644 index 000000000..752bd240b --- /dev/null +++ b/packages/console/src/assets/images/social.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/components/AppContent/components/UserInfo/index.tsx b/packages/console/src/components/AppContent/components/UserInfo/index.tsx index 5586296a1..9913a48ad 100644 --- a/packages/console/src/components/AppContent/components/UserInfo/index.tsx +++ b/packages/console/src/components/AppContent/components/UserInfo/index.tsx @@ -1,12 +1,13 @@ import { useLogto, IdTokenClaims } from '@logto/react'; import classNames from 'classnames'; -import { useEffect, useRef, useState, MouseEvent } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Dropdown, { DropdownItem } from '@/components/Dropdown'; import { Ring as Spinner } from '@/components/Spinner'; import { generateAvatarPlaceHolderById } from '@/consts/avatars'; import SignOut from '@/icons/SignOut'; +import { onKeyDownHandler } from '@/utilities/a11y'; import UserInfoSkeleton from '../UserInfoSkeleton'; import * as styles from './index.module.scss'; @@ -43,13 +44,18 @@ const UserInfo = () => { <>
{ + setShowDropdown(true); + })} onClick={() => { setShowDropdown(true); }} > {/* TODO: revert after SDK updated */} - + avatar
{username}
@@ -66,7 +72,7 @@ const UserInfo = () => { } - onClick={(event: MouseEvent) => { + onClick={(event) => { event.stopPropagation(); if (isLoading) { diff --git a/packages/console/src/components/AppError/index.tsx b/packages/console/src/components/AppError/index.tsx index 23bee6bfc..046906126 100644 --- a/packages/console/src/components/AppError/index.tsx +++ b/packages/console/src/components/AppError/index.tsx @@ -6,6 +6,7 @@ import ErrorDark from '@/assets/images/error-dark.svg'; import Error from '@/assets/images/error.svg'; import { useTheme } from '@/hooks/use-theme'; import { KeyboardArrowDown, KeyboardArrowUp } from '@/icons/Arrow'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -33,7 +34,12 @@ const AppError = ({ title, errorCode, errorMessage, callStack, children }: Props {errorMessage} {callStack && ( { + setIsDetailsOpen(!isDetailsOpen); + })} onClick={() => { setIsDetailsOpen(!isDetailsOpen); }} diff --git a/packages/console/src/components/CopyToClipboard/index.tsx b/packages/console/src/components/CopyToClipboard/index.tsx index 9fb8b7d89..66a0c4414 100644 --- a/packages/console/src/components/CopyToClipboard/index.tsx +++ b/packages/console/src/components/CopyToClipboard/index.tsx @@ -5,6 +5,7 @@ import { TFuncKey, useTranslation } from 'react-i18next'; import Copy from '@/icons/Copy'; import Eye from '@/icons/Eye'; import EyeClosed from '@/icons/EyeClosed'; +import { onKeyDownHandler } from '@/utilities/a11y'; import IconButton from '../IconButton'; import Tooltip from '../Tooltip'; @@ -57,6 +58,11 @@ const CopyToClipboard = ({ return (
{ + event.stopPropagation(); + })} onClick={(event) => { event.stopPropagation(); }} diff --git a/packages/console/src/components/DeleteConfirmModal/index.tsx b/packages/console/src/components/DeleteConfirmModal/index.tsx index 6772d6872..d0471158c 100644 --- a/packages/console/src/components/DeleteConfirmModal/index.tsx +++ b/packages/console/src/components/DeleteConfirmModal/index.tsx @@ -40,6 +40,7 @@ const DeleteConfirmModal = ({ {children} {expectedInput && ( { return ( ) => void; + onClick?: (event: MouseEvent | KeyboardEvent) => void; className?: string; children: ReactNode | Record; icon?: ReactNode; @@ -20,7 +22,13 @@ const DropdownItem = ({ iconClassName, type = 'default', }: Props) => ( -
  • +
  • {icon && {icon}} {children}
  • diff --git a/packages/console/src/components/Dropdown/index.tsx b/packages/console/src/components/Dropdown/index.tsx index 30c10056f..ddb0a10bf 100644 --- a/packages/console/src/components/Dropdown/index.tsx +++ b/packages/console/src/components/Dropdown/index.tsx @@ -3,6 +3,7 @@ import { ReactNode, RefObject, useRef } from 'react'; import ReactModal from 'react-modal'; import usePosition, { HorizontalAlignment } from '@/hooks/use-position'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -61,7 +62,13 @@ const Dropdown = ({ >
    {title &&
    {title}
    } -
      +
        {children}
    diff --git a/packages/console/src/components/MultiTextInput/index.tsx b/packages/console/src/components/MultiTextInput/index.tsx index bfe003174..0a191f4cc 100644 --- a/packages/console/src/components/MultiTextInput/index.tsx +++ b/packages/console/src/components/MultiTextInput/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import * as textButtonStyles from '@/components/TextButton/index.module.scss'; import Minus from '@/icons/Minus'; +import { onKeyDownHandler } from '@/utilities/a11y'; import ConfirmModal from '../ConfirmModal'; import IconButton from '../IconButton'; @@ -85,7 +86,13 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder )}
    ))} -
    +
    {t('general.add_another')}
    { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); @@ -64,10 +66,13 @@ const Radio = ({
    ({ className )} role="button" + tabIndex={0} + onKeyDown={onKeyDownHandler(() => { + if (!isReadOnly) { + setIsOpen(true); + } + })} onClick={() => { if (!isReadOnly) { setIsOpen(true); diff --git a/packages/console/src/components/TabNav/TabNavItem.tsx b/packages/console/src/components/TabNav/TabNavItem.tsx index e7cf06289..77214ff78 100644 --- a/packages/console/src/components/TabNav/TabNavItem.tsx +++ b/packages/console/src/components/TabNav/TabNavItem.tsx @@ -1,6 +1,8 @@ import classNames from 'classnames'; import { Link, useLocation } from 'react-router-dom'; +import { onKeyDownHandler } from '@/utilities/a11y'; + import * as styles from './TabNavItem.module.scss'; type Props = { @@ -16,7 +18,14 @@ const TabNavItem = ({ children, href, isActive, onClick }: Props) => { return (
    - {href ? {children} : {children}} + {href ? ( + {children} + ) : ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + {children} + + )}
    ); }; diff --git a/packages/console/src/mdx-components/DetailsSummary/index.tsx b/packages/console/src/mdx-components/DetailsSummary/index.tsx index a02bfeef5..4065e08b5 100644 --- a/packages/console/src/mdx-components/DetailsSummary/index.tsx +++ b/packages/console/src/mdx-components/DetailsSummary/index.tsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; -import { ReactNode, useState } from 'react'; +import { ReactNode, useState, useCallback } from 'react'; import AnimateHeight, { Height } from 'react-animate-height'; import ArrowRight from '@/assets/images/triangle-right.svg'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -15,14 +16,26 @@ const DetailsSummary = ({ children }: Props) => { const [isExpanded, setIsExpanded] = useState(false); const [height, setHeight] = useState(0); + const onClickHandler = useCallback(() => { + setIsExpanded(!isExpanded); + setHeight(height === 0 ? 'auto' : 0); + }, [height, isExpanded]); + return (
    { - setIsExpanded(!isExpanded); - setHeight(height === 0 ? 'auto' : 0); - }} + onKeyDown={onKeyDownHandler({ + Esc: () => { + setIsExpanded(false); + setHeight(0); + }, + Enter: onClickHandler, + ' ': onClickHandler, + })} + onClick={onClickHandler} > {summary} diff --git a/packages/console/src/mdx-components/Step/index.tsx b/packages/console/src/mdx-components/Step/index.tsx index d154d4f5c..90d3e1a29 100644 --- a/packages/console/src/mdx-components/Step/index.tsx +++ b/packages/console/src/mdx-components/Step/index.tsx @@ -1,6 +1,6 @@ import { AdminConsoleKey } from '@logto/phrases'; import classNames from 'classnames'; -import { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { PropsWithChildren, useEffect, useRef, useState, useCallback } from 'react'; import Button from '@/components/Button'; import Card from '@/components/Card'; @@ -10,6 +10,7 @@ import IconButton from '@/components/IconButton'; import Index from '@/components/Index'; import Spacer from '@/components/Spacer'; import { KeyboardArrowDown, KeyboardArrowUp } from '@/icons/Arrow'; +import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; @@ -54,13 +55,24 @@ const Step = ({ } }, [isExpanded]); + const onToggle = useCallback(() => { + setIsExpanded((expand) => !expand); + }, [setIsExpanded]); + return (
    { - setIsExpanded(!isExpanded); - }} + onKeyDown={onKeyDownHandler({ + Esc: () => { + setIsExpanded(false); + }, + Enter: onToggle, + ' ': onToggle, + })} + onClick={onToggle} > {
    { const api = useApi(); const navigate = useNavigate(); const formMethods = useForm(); + const documentationUrl = useDocumentationUrl(); const { handleSubmit, @@ -145,15 +147,21 @@ const ApplicationDetails = () => {
    {/* TODO: @Charles figure out a better way to check guide availability */} - {data.type !== ApplicationType.MachineToMachine && ( -