diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e2a2d6655..76304eecb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing to Logto. We respect the time of commun **Table of contents** -- [(Draft) Contribute to Logto](#draft-contribute-to-logto) +- [(Draft) Contribute to Logto monorepo](#draft-contribute-to-logto-monorepo) - [Contribution Type](#contribution-type) - [Bug fixes](#bug-fixes) - [Connectors](#connectors) @@ -12,10 +12,12 @@ Thanks for your interest in contributing to Logto. We respect the time of commun - [Set up the dev environment](#set-up-the-dev-environment) - [Prerequisites](#prerequisites) - [Clone and install dependencies](#clone-and-install-dependencies) - - [Set up environment variables (optional)](#set-up-environment-variables-optional) + - [Set up database](#set-up-database) + - [Database alteration](#database-alteration) + - [Add connectors (optional)](#add-connectors-optional) - [Start dev](#start-dev) - - [Note for a fresh setup](#note-for-a-fresh-setup) - [Make changes](#make-changes) + - [Commit and create pull request](#commit-and-create-pull-request) ## Contribution Type @@ -53,7 +55,7 @@ Since a new connector means a new Node.js package, we encourage you to separate If you find some feature is related to customer identity and doesn't belong to a specific connector, then most likely, it's a core feature. -Since Logto is still in the early stage, it may already be in our roadmap. Until we have a publicly accessible place for the roadmap, join our [Discord channel](https://discord.gg/cyWnux4cH6) or [email us](mailto:contact@logto.io) to get the details. +Since Logto is still in the early stage, it may already be in [our roadmap](https://silverhand.notion.site/Logto-Public-Roadmap-d6a1ad19039946b7b1139811aed82dcc). You can also join our [Discord channel](https://discord.gg/vRvwuwgpVX) or [email us](mailto:contact@logto.io) to get the details. The concept of feature varies by the situation, so we'll work with you to figure out the best way to contribute before starting. @@ -79,12 +81,27 @@ pnpm i It may take a while to install dependencies. -### Set up environment variables (optional) +### Set up database -The root `npm start` is optimized for public release, which carries the `--from-root` parameter. In the dev environment, usually, we read `.env` from the package location instead. +Create a `.env` file with the following content in the project root, or set the environment variable directly: -- If you already have a `.env` in the project root, move it into `packages/core/` before continuing. -- If it's a fresh setup, no action is needed now. You can follow the command line questions afterward. +```env +DB_URL=postgresql://your-postgres-dsn/logto # Replace with your own +``` + +Then run `pnpm cli db seed` to seed data into your database. + +### Database alteration + +If you are upgrading your dev environment from an older version, or facing the `Found undeployed database alterations...` error when starting Logto, you need to deploy the database alteration first. + +Run `pnpm alteration deploy` and start Logto again. See [Database alteration](https://docs.logto.io/docs/tutorials/using-cli/database-alteration) for reference of this command. + +If you are developing something with database alterations, see [packages/schemas/alteration](https://github.com/logto-io/logto/tree/master/packages/schemas/alterations) to learn more. + +### Add connectors (optional) + +Run `pnpm cli connector add --official -p .` to add all Logto official connectors. See [Manage connectors](https://docs.logto.io/docs/tutorials/using-cli/manage-connectors) for details about managing connectors via CLI. ## Start dev @@ -94,19 +111,7 @@ Run the command below in the project root: pnpm dev ``` -The command will do several things in order: - -1. Compile `connectors`, `schemas`, and `phrases`. -2. Compile `core`, `ui`, `console`, and `demo-app`. -3. Watch the changes of the packages in step 2. - -### Note for a fresh setup - -If you start dev with no `.env` provided (a fresh setup), it'll have a great possibility that you'll miss the first question. - -This is because `parcel` uses `ora` to show an in-line spinner which will overwrite the question, which asks if you'd like to generate a new cookie key. - -Just press enter when you see the message like `✨ Built in 8.21s` to generate a new key by Logto. +The command will watch the changes in most of the packages and restart services when needed. ## Make changes diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index bc25bf5e9..7ab73c0a9 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Commitlint run: npx commitlint --from HEAD~${{ github.event.pull_request.commits }} --to HEAD diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 113c2a46f..110ce31e5 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -19,17 +19,10 @@ jobs: - uses: actions/checkout@v3 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Build - run: pnpm -- lerna run build --stream - - - name: Add the mock connectors for integration tests only - run: | - pnpm add-connector @logto/connector-mock-sms - pnpm add-connector @logto/connector-mock-email - pnpm add-connector @logto/connector-mock-social - working-directory: packages/core + run: pnpm -r build - name: Package run: ./package.sh @@ -60,15 +53,14 @@ jobs: cp tests/package.json ./ - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 with: run-install: false # Setup integration test - name: Install dependencies run: | - mv tests /tmp/tests - cd /tmp/tests + cd tests pnpm i pnpm prepack @@ -81,15 +73,20 @@ jobs: name: integration-test-${{ github.sha }} - name: Extract - run: tar -xzf logto.tar.gz + working-directory: tests + run: | + npm run cli init -- -p ../logto --db postgres://postgres:postgres@localhost:5432/postgres --no-oc --du ../logto.tar.gz + + - 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 - name: Run Logto - run: node . --from-root --all-yes & - working-directory: logto/packages/core + working-directory: logto/ + run: npm start & env: INTEGRATION_TEST: true - NODE_ENV: production - DB_URL_DEFAULT: postgres://postgres:postgres@localhost:5432 - name: Sleep for 5 seconds run: sleep 5 @@ -97,7 +94,7 @@ jobs: # Test - name: Run tests run: | - cd /tmp/tests/packages/integration-tests + cd tests/packages/integration-tests pnpm start env: INTEGRATION_TESTS_LOGTO_URL: http://localhost:3001 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 56b3c7ef7..44d0150b9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Build run: pnpm ci:build @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Prepack run: pnpm prepack @@ -51,7 +51,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Prepack run: pnpm prepack @@ -70,3 +70,19 @@ jobs: with: flags: ui directory: ./packages/ui + + main-dockerize: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build + uses: docker/build-push-action@v3 + with: + context: . diff --git a/.github/workflows/master-codecov-report.yml b/.github/workflows/master-codecov-report.yml index 1a1a02087..be9e16c8a 100644 --- a/.github/workflows/master-codecov-report.yml +++ b/.github/workflows/master-codecov-report.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Prepack run: pnpm prepack diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1a1cb4885..e71f4fa5b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,7 +36,7 @@ jobs: git_commit_gpgsign: true - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Configure Git user run: | @@ -46,7 +46,10 @@ jobs: - name: Publish to GitHub # add `no-verify-access` due to https://github.com/lerna/lerna/issues/2788 run: | - pnpm lerna publish \ + pnpm \ + --package=conventional-changelog-conventionalcommits \ + --package=lerna@^5.0.0 \ + dlx lerna publish \ -m "release: %s" \ --conventional-commits \ --preid=${{ github.event.inputs.preid }} \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39c4634d5..f5609ffb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ concurrency: jobs: dockerize: + environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }} runs-on: ubuntu-latest steps: @@ -79,6 +80,7 @@ jobs: create-github-release: + environment: release runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') @@ -88,7 +90,7 @@ jobs: fetch-depth: 0 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Extract changelog run: | @@ -100,7 +102,7 @@ jobs: > /tmp/changelog.txt - name: Build - run: pnpm -- lerna run build --stream + run: pnpm -r build - name: Package run: ./package.sh diff --git a/.github/workflows/upload-annotations.yml b/.github/workflows/upload-annotations.yml index 7508e1e05..2ae60272d 100644 --- a/.github/workflows/upload-annotations.yml +++ b/.github/workflows/upload-annotations.yml @@ -22,13 +22,13 @@ jobs: - uses: actions/checkout@v3 - name: Setup Node and pnpm - uses: silverhand-io/actions-node-pnpm-run-steps@v1.2.3 + uses: silverhand-io/actions-node-pnpm-run-steps@v2 - name: Prepack run: pnpm prepack - name: Lint with Report - run: pnpm -- lerna run --parallel lint:report && node merge-eslint-reports.js + run: pnpm -r --parallel lint:report && node merge-eslint-reports.js - name: Annotate Code Linting Results uses: ataylorme/eslint-annotate-action@1.2.0 diff --git a/.gitpod.yml b/.gitpod.yml index 097744dd9..b2887d25f 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -1,23 +1,25 @@ tasks: - - name: DB Server + - name: Database init: docker pull postgres:14-alpine command: docker run -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=p0stgr3s postgres:14-alpine - - name: Logto Dev Mode + + - name: Logto dev init: | pnpm i pnpm prepack cd packages/core pnpm build - pnpm add-official-connectors cd - + pnpm cli connector add --official -p . command: | + gp ports await 5432 + sleep 3 export ENDPOINT=$(gp url 3001) - pnpm lerna --ignore=@logto/integration-test run --parallel dev + pnpm cli db seed + pnpm start:dev env: - ALL_YES: 1 - NO_INQUIRY: 0 TRUST_PROXY_HEADER: 1 - DB_URL_DEFAULT: postgres://postgres:p0stgr3s@127.0.0.1:5432 + DB_URL: postgres://postgres:p0stgr3s@127.0.0.1:5432 ports: - name: Logto diff --git a/.husky/pre-commit b/.husky/pre-commit index f6bbe2ee7..058f96add 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -FORCE_COLOR=1 pnpm -- lerna run --concurrency 1 --stream precommit --since HEAD --exclude-dependents +FORCE_COLOR=1 pnpm -r --filter "[HEAD]" precommit diff --git a/CHANGELOG.md b/CHANGELOG.md index e6749bc89..471e9c05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,79 @@ 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.12](https://github.com/logto-io/logto/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2022-10-19) + + +### Bug Fixes + +* add tables to schemas files ([582f3d6](https://github.com/logto-io/logto/commit/582f3d615862c3d8b2c00d8e60a3617429d48e30)) +* handle versioning when no `next-*.ts` found ([#2202](https://github.com/logto-io/logto/issues/2202)) ([61336df](https://github.com/logto-io/logto/commit/61336dfbc833c96ddce88be5082b82a30527ee73)) +* make packages public ([e24fd04](https://github.com/logto-io/logto/commit/e24fd0479bc20c92bd38b5e214abe441404ce496)) + + + +## [1.0.0-beta.11](https://github.com/logto-io/logto/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2022-10-19) + + +### ⚠ BREAKING CHANGES + +* update scripts + +### Features + +* `npm create` compatibility ([a5cd73d](https://github.com/logto-io/logto/commit/a5cd73d961766c7c72180795051feabe9793fc7d)) +* add vertical center support ([#2032](https://github.com/logto-io/logto/issues/2032)) ([5eeb06e](https://github.com/logto-io/logto/commit/5eeb06e301b06d3caad65ece1b7b05cf6e160dd4)) +* **cli:** `db alteration deploy` command ([a5280a2](https://github.com/logto-io/logto/commit/a5280a2afd3d5822e78d1f115ab6f6fdbb993261)) +* **cli:** `db seed oidc` command ([911117a](https://github.com/logto-io/logto/commit/911117a785fd43ea03473f42835f2680cccca7be)) +* **cli:** `db seed` command ([5c7000d](https://github.com/logto-io/logto/commit/5c7000ddc30e316bd17f34d71d51c17016efec76)) +* **cli:** add `download-url` option for install ([5dda0a6](https://github.com/logto-io/logto/commit/5dda0a6dd0e04468c078e3581e68a614ce23404c)) +* **cli:** add connector command ([4ccbe4a](https://github.com/logto-io/logto/commit/4ccbe4ac6566aff0db1cd98a74441640677f6060)) +* **cli:** command `init/i/install` ([f05691b](https://github.com/logto-io/logto/commit/f05691b4319279a49bf0bc87ba656b7990d52e53)) +* **cli:** database config command ([0eb306a](https://github.com/logto-io/logto/commit/0eb306a61cf88b8be3be86852cb66b1d99ad713f)) +* **cli:** get/set db config key ([0eff1e3](https://github.com/logto-io/logto/commit/0eff1e3591129802f3e9b3286652ef6fc8619cf5)) +* **cli:** init ([0c6462d](https://github.com/logto-io/logto/commit/0c6462dbdae1b2518003e5cb3ea5604300200196)) +* **cli:** list connectors ([dcb9142](https://github.com/logto-io/logto/commit/dcb91428e6ef1021e383270e66d3e67bfc83e593)) +* **cli:** remove connectors ([7d257c4](https://github.com/logto-io/logto/commit/7d257c45bfa37298c287b3ac867acd0606c4f028)) +* **console:** add a11y lint to ac ([#2066](https://github.com/logto-io/logto/issues/2066)) ([37d2b0c](https://github.com/logto-io/logto/commit/37d2b0ce5c09658d5e49be84b891d9a0d83f6f5c)) +* **console:** add custom language ([#2029](https://github.com/logto-io/logto/issues/2029)) ([800ac7f](https://github.com/logto-io/logto/commit/800ac7fcd9592875df29d897e3a704fc6a73fee1)) +* **console:** auto detect language setting ([#1941](https://github.com/logto-io/logto/issues/1941)) ([cdfaf8b](https://github.com/logto-io/logto/commit/cdfaf8b1c7fd268f205e4679cfc762d7e3eedfea)) +* **console:** delete custom phrases ([#2065](https://github.com/logto-io/logto/issues/2065)) ([68e8884](https://github.com/logto-io/logto/commit/68e88840bfe4f50682c028188f32bc2480e8d8d7)) +* **console:** display unsaved alert on custom phrases changed ([#1994](https://github.com/logto-io/logto/issues/1994)) ([0679a6a](https://github.com/logto-io/logto/commit/0679a6a67c71203e0bae3489768184a6e564937d)) +* **console:** manage language ([#1981](https://github.com/logto-io/logto/issues/1981)) ([48832e5](https://github.com/logto-io/logto/commit/48832e50548421b876deaf10b1d3379674e7f562)) +* **core,phrases:** add GET /phrase route ([#1959](https://github.com/logto-io/logto/issues/1959)) ([7ce55a8](https://github.com/logto-io/logto/commit/7ce55a8458166d1ca7453f3f637aed202860bf6c)) +* **ui:** add a11y support ([#2076](https://github.com/logto-io/logto/issues/2076)) ([2249d71](https://github.com/logto-io/logto/commit/2249d717a8928597d00c383c268d6fdc506ac437)) +* **ui:** add reset password error handling flow ([#2079](https://github.com/logto-io/logto/issues/2079)) ([afa2ac4](https://github.com/logto-io/logto/commit/afa2ac47ee461e3526f61594e456d484fd3166af)) +* **ui:** global confirm modal ([#2018](https://github.com/logto-io/logto/issues/2018)) ([f1ca49c](https://github.com/logto-io/logto/commit/f1ca49c89253daef8b47ec88e30f69df818374d1)) + + +### Bug Fixes + +* add publish config for public packages ([#2192](https://github.com/logto-io/logto/issues/2192)) ([38f664c](https://github.com/logto-io/logto/commit/38f664c27c4927970f40336b04154a5803cb5dc0)) +* add redirectURI validation on frontend & backend ([#1874](https://github.com/logto-io/logto/issues/1874)) ([4b0970b](https://github.com/logto-io/logto/commit/4b0970b6d8c6647a6e68bf27fe3db3aeb635768e)) +* alteration script in dev ([9ebb3dd](https://github.com/logto-io/logto/commit/9ebb3ddfd963f6459ea332dbe1384058f77b453b)) +* **cli:** `chooseAlterationsByVersion` should contain the last `next` version alteration script ([#2175](https://github.com/logto-io/logto/issues/2175)) ([fd50304](https://github.com/logto-io/logto/commit/fd50304f5ff5ffbc985695eaa73c1bc56b1ca061)) +* **cli:** fix skip-when-exists option ([#2180](https://github.com/logto-io/logto/issues/2180)) ([4ce2073](https://github.com/logto-io/logto/commit/4ce207369228d404d919c491ba398acedcfd55fa)) +* **console:** checkbox styles ([7c85e50](https://github.com/logto-io/logto/commit/7c85e50c4597f6ed0a19384916ea6ef1bb3974a5)) +* **console:** clear select state on close modal ([#2071](https://github.com/logto-io/logto/issues/2071)) ([b6b9d7c](https://github.com/logto-io/logto/commit/b6b9d7ce80aefe7341b3167e18ce4af291052015)) +* **console:** language editor form should be dirty on clear button clicked ([#2037](https://github.com/logto-io/logto/issues/2037)) ([1223d23](https://github.com/logto-io/logto/commit/1223d23eb3f13cce707f6cd5eecd043c476f3514)) +* **console:** remove connector id and prevent text overflow ([#2072](https://github.com/logto-io/logto/issues/2072)) ([05b5025](https://github.com/logto-io/logto/commit/05b50250a387635649614aaeeec9757e7034a19d)) +* **console:** responsive modal items layout ([#2160](https://github.com/logto-io/logto/issues/2160)) ([ac38a7f](https://github.com/logto-io/logto/commit/ac38a7f3ac13b90ffb2ea8a94d40a390d652a62b)) +* **console:** save generated password in session storage ([#2116](https://github.com/logto-io/logto/issues/2116)) ([8a7f875](https://github.com/logto-io/logto/commit/8a7f875767f5d70edc41509ddd1973b87ad16ee9)) +* **console:** set undefined value to empty string in custom phrases ([#2074](https://github.com/logto-io/logto/issues/2074)) ([81f9fbc](https://github.com/logto-io/logto/commit/81f9fbc48379afc7de5d84e3614097ee37a1424b)) +* **console:** show correct password after reset ([#2063](https://github.com/logto-io/logto/issues/2063)) ([02c082c](https://github.com/logto-io/logto/commit/02c082cb71258a931925df87126060fa9d9a2c5d)) +* **console:** use fallback language in preview ([#1960](https://github.com/logto-io/logto/issues/1960)) ([f25ae4d](https://github.com/logto-io/logto/commit/f25ae4de1477feca5a8e077cb05146bb13719e6f)) +* **core:** fix deletePasscodeByIds bug ([#2049](https://github.com/logto-io/logto/issues/2049)) ([11b605a](https://github.com/logto-io/logto/commit/11b605a3e7bcef5ecbe24c5a39b8a1a081a54e88)) +* **deps:** update dependency @logto/language-kit to v1.0.0-beta.16 ([89e4800](https://github.com/logto-io/logto/commit/89e4800ca8e30cbf62a0000fa350ee2f5dd094de)) +* **ui:** fix ut ([9ea6a8c](https://github.com/logto-io/logto/commit/9ea6a8c8e94e116d8efbbff63b39738162cbaec1)) +* **ui:** revert color token changes in ui as it uses different design system ([489e2b3](https://github.com/logto-io/logto/commit/489e2b3a1129fbbf824955e4697c1d64ff294d95)) + + +### Miscellaneous Chores + +* update scripts ([c96495a](https://github.com/logto-io/logto/commit/c96495ad4ef778a006f0307a9e0a4bf47d0bfdc7)) + + + ## [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) diff --git a/Dockerfile b/Dockerfile index 16310305d..81641a922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,10 @@ RUN apk add --no-cache python3 make g++ # Install dependencies and build RUN pnpm i -RUN pnpm -- lerna run build --stream +RUN pnpm -r build # Add official connectors -WORKDIR /etc/logto/packages/core -RUN pnpm add-official-connectors -WORKDIR /etc/logto +RUN pnpm cli connector add --official -p . # Prune dependencies for production RUN rm -rf node_modules packages/*/node_modules @@ -30,5 +28,4 @@ FROM node:16-alpine as app WORKDIR /etc/logto COPY --from=builder /etc/logto . EXPOSE 3001 -ENV NO_INQUIRY true ENTRYPOINT ["npm", "start"] diff --git a/README.md b/README.md index 5ad6cdb9e..8119a0abe 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![checks](https://img.shields.io/github/checks-status/logto-io/logto/master)](https://github.com/logto-io/logto/actions?query=branch%3Amaster) [![release](https://img.shields.io/github/v/release/logto-io/logto?color=7958FF)](https://github.com/logto-io/logto/releases) [![core coverage](https://img.shields.io/codecov/c/github/logto-io/logto?label=core%20coverage)](https://app.codecov.io/gh/logto-io/logto) -[![gitpod](https://img.shields.io/badge/gitpod-available-f09439)](https://gitpod.io/#https://github.com/logto-io/logto) +[![gitpod](https://img.shields.io/badge/gitpod-available-f09439)](https://gitpod.io/#https://github.com/logto-io/demo) [![render](https://img.shields.io/badge/render-deploy-5364e9)](https://render.com/deploy?repo=https://github.com/logto-io/logto) Logto[^info] helps you build the sign-in, auth, and user identity within minutes. @@ -44,7 +44,7 @@ Boringly, we call it "[customer identity access management](https://en.wikipedia #### Online demo (GitPod) -[Click here](https://gitpod.io/#https://github.com/logto-io/logto) to launch Logto via GitPod. Once you see the message like `App is running at https://3001-...gitpod.io` in the terminal, press Cmd (or Ctrl) and click the URL to continue your Logto journey. +[Click here](https://gitpod.io/#https://github.com/logto-io/demo) to launch Logto via GitPod. Once you see the message like `App is running at https://3001-...gitpod.io` in the terminal, press Cmd (or Ctrl) and click the URL to continue your Logto journey. #### Docker Compose @@ -55,12 +55,12 @@ curl -fsSL https://raw.githubusercontent.com/logto-io/logto/HEAD/docker-compose. TAG=prerelease docker compose -p logto -f - up ``` -#### One-liner script +#### npm-init Requires [Node.js](https://nodejs.org/) `^16.13.0` + [PostgreSQL](https://postgresql.org/) `^14.0`. ```bash -node -e "$(printf "%s" "$(curl -fsSL https://raw.githubusercontent.com/logto-io/logto/HEAD/install.js)")" +npm init @logto ``` ## Language support diff --git a/commitlint.config.js b/commitlint.config.js index c3ce6e7c9..a2c1d2f00 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,10 +1,14 @@ const { rules } = require('@commitlint/config-conventional'); +const isCi = process.env.CI === 'true'; + /** @type {import('@commitlint/types').UserConfig} **/ module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [...rules['type-enum'][2], 'api', 'release']], - 'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'ui', 'deps', 'connector-core', 'cli']] + 'scope-enum': [2, 'always', ['connector', 'console', 'core', 'demo-app', 'test', 'phrases', 'schemas', 'shared', 'ui', 'deps', 'connector-core', 'cli']], + // Slightly increase the tolerance to allow the appending PR number + ...(isCi && { 'header-max-length': [2, 'always', 110] }) }, }; diff --git a/docker-compose.yml b/docker-compose.yml index 9a0f21562..3c794e511 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,14 +6,13 @@ services: postgres: condition: service_healthy image: ghcr.io/logto-io/logto:${TAG-latest} + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] ports: - 3001:3001 environment: - - ALL_YES=1 - - NO_INQUIRY=0 - TRUST_PROXY_HEADER=1 - - DB_URL_DEFAULT=postgres://postgres:p0stgr3s@postgres:5432 - - ENDPOINT + - DB_URL=postgres://postgres:p0stgr3s@postgres:5432/logto + - ENDPOINT # Mandatory. Mapping env to the container. postgres: image: postgres:14-alpine user: postgres diff --git a/install.js b/install.js deleted file mode 100755 index b0c2dc6ff..000000000 --- a/install.js +++ /dev/null @@ -1,72 +0,0 @@ -const { execSync, spawn, spawnSync } = require('child_process'); -const { existsSync } = require('fs'); -const readline = require('readline'); - -const isVersionGreaterThan = (version, targetMajor) => Number(version.split('.')[0]) >= targetMajor; - -const trimV = (version) => version.startsWith('v') ? version.slice(1) : version; - -const question = async (query) => new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.question(query, (answer) => { - rl.close(); - resolve(answer); - }); -}); - -const confirm = async (query) => { - const answer = await question(`${query} (Y/n) `); - return answer === '' || ['y', 'yes', 'yep', 'yeah'].includes(answer.toLowerCase()); -}; - -const safeExecSync = (command) => { - try { - return execSync(command, { encoding: 'utf-8' }); - } catch {} -}; - -const directory = 'logto'; -const nodeMajorVersion = 16; -const postgresMajorVersion = 14; - -(async () => { - if (existsSync(directory)) { - throw new Error(`\`${directory}\` already exists in the current directory.`); - } - - const nodeVersion = execSync('node -v', { encoding: 'utf-8' }); - - if (!isVersionGreaterThan(trimV(nodeVersion), nodeMajorVersion)) { - throw new Error(`Logto requires NodeJS >= ${nodeMajorVersion}.0.0.`); - } - - const pgOutput = safeExecSync('postgres --version') ?? ''; - const pgArray = pgOutput.split(' '); - const pgVersion = pgArray[pgArray.length - 1]; - - if (!isVersionGreaterThan(trimV(pgVersion), postgresMajorVersion)) { - const answer = await confirm(`Logto requires PostgreSQL >= ${postgresMajorVersion}.0.0 but cannot find in the current environment.\nDo you have a remote PostgreSQL instance ready?`); - if (!answer) { - process.exit(1); - } - } - - // Download and extract - spawnSync( - 'sh', - ['-c', 'curl -L https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz | tar -xz'], - { stdio: 'inherit' }, - ); - - const startCommand = `cd ${directory} && npm start`; - const answer = await confirm('Would you like to start Logto now?'); - - if (answer) { - spawn('sh', ['-c', startCommand], { stdio: 'inherit' }); - } else { - console.log(`You can use \`${startCommand}\` to start Logto. Happy hacking!`); - } -})(); diff --git a/lerna.json b/lerna.json deleted file mode 100644 index 560a0bfde..000000000 --- a/lerna.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": "1.0.0-beta.10", - "npmClient": "pnpm", - "useWorkspaces": true, - "changelogPreset": "conventionalcommits" -} diff --git a/merge-eslint-reports.js b/merge-eslint-reports.js index 0a37c4436..2012ec7d1 100644 --- a/merge-eslint-reports.js +++ b/merge-eslint-reports.js @@ -3,7 +3,7 @@ const fs = require('fs'); const directories = fs.readdirSync('./packages'); const reports = directories // Filter out docs temporarily - .filter((dir) => dir !== 'docs') + .filter((dir) => !['docs', 'create'].includes(dir)) .map((dir) => fs.readFileSync(`./packages/${dir}/report.json`, { encoding: 'utf-8' })); const merged = []; diff --git a/package.json b/package.json index 7e25a31ce..af11b8a5c 100644 --- a/package.json +++ b/package.json @@ -5,24 +5,23 @@ "scripts": { "preinstall": "npx only-allow pnpm", "version": "pnpm i --frozen-lockfile=false && git add pnpm-lock.yaml", - "lerna": "lerna", - "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-tests run --parallel dev", - "start": "cd packages/core && NODE_ENV=production node . --from-root", - "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:ci" + "prepack": "pnpm -r prepack", + "dev": "pnpm -r prepack && pnpm start:dev", + "start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests dev", + "start": "cd packages/core && NODE_ENV=production node .", + "cli": "logto", + "alteration": "logto db alt", + "ci:build": "pnpm -r build", + "ci:lint": "pnpm -r --parallel lint", + "ci:stylelint": "pnpm -r --parallel stylelint", + "ci:test": "pnpm -r --parallel test:ci" }, "devDependencies": { "@commitlint/cli": "^17.0.0", "@commitlint/config-conventional": "^17.0.0", "@commitlint/types": "^17.0.0", "husky": "^8.0.0", - "lerna": "^5.0.0", "typescript": "^4.7.4" }, "workspaces": { @@ -45,8 +44,12 @@ "pnpm": { "peerDependencyRules": { "allowedVersions": { - "react": "^18.0.0" + "react": "^18.0.0", + "jest": "^29.1.2" } } + }, + "dependencies": { + "@logto/cli": "^1.0.0-beta.10" } } diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 000000000..5b78a4af2 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,2 @@ +alteration-scripts/ +src/package-json.ts diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 000000000..fcd000b1b --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,37 @@ +# Change Log + +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.12](https://github.com/logto-io/logto/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2022-10-19) + +**Note:** Version bump only for package @logto/cli + + + + + +## [1.0.0-beta.11](https://github.com/logto-io/logto/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2022-10-19) + + +### Features + +* `npm create` compatibility ([a5cd73d](https://github.com/logto-io/logto/commit/a5cd73d961766c7c72180795051feabe9793fc7d)) +* **cli:** `db alteration deploy` command ([a5280a2](https://github.com/logto-io/logto/commit/a5280a2afd3d5822e78d1f115ab6f6fdbb993261)) +* **cli:** `db seed oidc` command ([911117a](https://github.com/logto-io/logto/commit/911117a785fd43ea03473f42835f2680cccca7be)) +* **cli:** `db seed` command ([5c7000d](https://github.com/logto-io/logto/commit/5c7000ddc30e316bd17f34d71d51c17016efec76)) +* **cli:** add `download-url` option for install ([5dda0a6](https://github.com/logto-io/logto/commit/5dda0a6dd0e04468c078e3581e68a614ce23404c)) +* **cli:** add connector command ([4ccbe4a](https://github.com/logto-io/logto/commit/4ccbe4ac6566aff0db1cd98a74441640677f6060)) +* **cli:** command `init/i/install` ([f05691b](https://github.com/logto-io/logto/commit/f05691b4319279a49bf0bc87ba656b7990d52e53)) +* **cli:** database config command ([0eb306a](https://github.com/logto-io/logto/commit/0eb306a61cf88b8be3be86852cb66b1d99ad713f)) +* **cli:** get/set db config key ([0eff1e3](https://github.com/logto-io/logto/commit/0eff1e3591129802f3e9b3286652ef6fc8619cf5)) +* **cli:** list connectors ([dcb9142](https://github.com/logto-io/logto/commit/dcb91428e6ef1021e383270e66d3e67bfc83e593)) +* **cli:** remove connectors ([7d257c4](https://github.com/logto-io/logto/commit/7d257c45bfa37298c287b3ac867acd0606c4f028)) + + +### Bug Fixes + +* add publish config for public packages ([#2192](https://github.com/logto-io/logto/issues/2192)) ([38f664c](https://github.com/logto-io/logto/commit/38f664c27c4927970f40336b04154a5803cb5dc0)) +* alteration script in dev ([9ebb3dd](https://github.com/logto-io/logto/commit/9ebb3ddfd963f6459ea332dbe1384058f77b453b)) +* **cli:** `chooseAlterationsByVersion` should contain the last `next` version alteration script ([#2175](https://github.com/logto-io/logto/issues/2175)) ([fd50304](https://github.com/logto-io/logto/commit/fd50304f5ff5ffbc985695eaa73c1bc56b1ca061)) +* **cli:** fix skip-when-exists option ([#2180](https://github.com/logto-io/logto/issues/2180)) ([4ce2073](https://github.com/logto-io/logto/commit/4ce207369228d404d919c491ba398acedcfd55fa)) diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts new file mode 100644 index 000000000..f3ba355b8 --- /dev/null +++ b/packages/cli/jest.config.ts @@ -0,0 +1,7 @@ +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 df2c04f04..ebbc85f10 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,14 +1,16 @@ { "name": "@logto/cli", - "version": "1.0.0-beta.10", + "version": "1.0.0-beta.12", "description": "Logto CLI.", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", "license": "MPL-2.0", + "publishConfig": { + "access": "public" + }, "main": "lib/index.js", "bin": { - "logto": "bin/logto", - "lg": "bin/logto" + "logto": "bin/logto" }, "files": [ "bin", @@ -20,11 +22,15 @@ }, "scripts": { "precommit": "lint-staged", - "build": "rimraf lib && tsc", + "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", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", "start": "node .", - "dev": "ts-node src/index.ts", + "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": { @@ -34,23 +40,41 @@ "url": "https://github.com/logto-io/logto/issues" }, "dependencies": { + "@logto/schemas": "^1.0.0-beta.12", + "@logto/shared": "^1.0.0-beta.12", + "@silverhand/essentials": "^1.3.0", "chalk": "^4.1.2", + "decamelize": "^5.0.0", + "dotenv": "^16.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", - "prompts": "^2.4.2", + "p-retry": "^4.6.1", + "roarr": "^7.11.0", "semver": "^7.3.7", - "tar": "^6.1.11" + "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/ts-config": "1.0.0", - "@types/decompress": "^4.2.4", + "@silverhand/eslint-config": "1.2.0", + "@silverhand/jest-config": "1.2.2", + "@silverhand/ts-config": "1.2.1", + "@types/fs-extra": "^9.0.13", + "@types/inquirer": "^8.2.1", + "@types/jest": "^29.1.2", "@types/node": "^16.0.0", - "@types/prompts": "^2.0.14", "@types/semver": "^7.3.12", "@types/tar": "^6.1.2", + "@types/yargs": "^17.0.13", "eslint": "^8.21.0", + "jest": "^29.1.2", "lint-staged": "^13.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", @@ -58,7 +82,10 @@ "typescript": "^4.7.4" }, "eslintConfig": { - "extends": "@silverhand" + "extends": "@silverhand", + "ignorePatterns": [ + "src/package-json.ts" + ] }, "prettier": "@silverhand/eslint-config/.prettierrc" } diff --git a/packages/cli/src/commands/connector/add.ts b/packages/cli/src/commands/connector/add.ts new file mode 100644 index 000000000..931957187 --- /dev/null +++ b/packages/cli/src/commands/connector/add.ts @@ -0,0 +1,44 @@ +import { CommandModule } from 'yargs'; + +import { log } from '../../utilities'; +import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils'; + +const add: CommandModule< + { path?: string }, + { packages?: string[]; path?: string; official: boolean } +> = { + command: ['add [packages...]', 'a', 'install', 'i'], + describe: 'Add specific Logto connectors', + builder: (yargs) => + yargs + .positional('packages', { + describe: 'The connector package names to add', + type: 'string', + array: true, + default: undefined, + }) + .option('official', { + alias: 'o', + type: 'boolean', + default: false, + describe: + 'Add all official connectors.\n' + + "If it's true, the specified package names will be ignored.", + }), + handler: async ({ packages: packageNames, path, official }) => { + const instancePath = await inquireInstancePath(path); + + if (official) { + await addOfficialConnectors(instancePath); + } else { + if (!packageNames?.length) { + log.error('No connector name provided'); + } + await addConnectors(instancePath, packageNames); + } + + log.info('Restart your Logto instance to get the changes reflected.'); + }, +}; + +export default add; diff --git a/packages/cli/src/commands/connector/index.ts b/packages/cli/src/commands/connector/index.ts new file mode 100644 index 000000000..a5ff36788 --- /dev/null +++ b/packages/cli/src/commands/connector/index.ts @@ -0,0 +1,25 @@ +import { noop } from '@silverhand/essentials'; +import { CommandModule } from 'yargs'; + +import add from './add'; +import list from './list'; +import remove from './remove'; + +const connector: CommandModule = { + command: ['connector', 'c', 'connectors'], + describe: 'Command for Logto connectors', + builder: (yargs) => + yargs + .option('path', { + alias: 'p', + type: 'string', + describe: 'The path to your Logto instance directory', + }) + .command(add) + .command(list) + .command(remove) + .demandCommand(1), + handler: noop, +}; + +export default connector; diff --git a/packages/cli/src/commands/connector/list.ts b/packages/cli/src/commands/connector/list.ts new file mode 100644 index 000000000..ae2af5f11 --- /dev/null +++ b/packages/cli/src/commands/connector/list.ts @@ -0,0 +1,30 @@ +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { getConnectorPackagesFrom, isOfficialConnector } from './utils'; + +const logConnectorNames = (type: string, names: string[]) => { + if (names.length === 0) { + return; + } + + console.log(); + console.log(chalk.blue(type)); + console.log(names.map((value) => ' ' + value).join('\n')); +}; + +const list: CommandModule<{ path?: string }, { path?: string }> = { + command: ['list', 'l'], + describe: 'List added Logto connectors', + handler: async ({ path: inputPath }) => { + const packages = await getConnectorPackagesFrom(inputPath); + const packageNames = packages.map(({ name }) => name); + const officialPackages = packageNames.filter((name) => isOfficialConnector(name)); + const thirdPartyPackages = packageNames.filter((name) => !isOfficialConnector(name)); + + logConnectorNames('official'.toUpperCase(), officialPackages); + logConnectorNames('3rd-party'.toUpperCase(), thirdPartyPackages); + }, +}; + +export default list; diff --git a/packages/cli/src/commands/connector/remove.ts b/packages/cli/src/commands/connector/remove.ts new file mode 100644 index 000000000..c3a4f37da --- /dev/null +++ b/packages/cli/src/commands/connector/remove.ts @@ -0,0 +1,59 @@ +import chalk from 'chalk'; +import fsExtra from 'fs-extra'; +import { CommandModule } from 'yargs'; + +import { log } from '../../utilities'; +import { getConnectorPackagesFrom } from './utils'; + +const remove: CommandModule<{ path?: string }, { path?: string; packages?: string[] }> = { + command: ['remove [packages...]', 'rm', 'delete'], + describe: 'Remove existing Logto connectors', + builder: (yargs) => + yargs.positional('packages', { + describe: 'The connector package names to remove', + type: 'string', + array: true, + default: undefined, + }), + handler: async ({ path: inputPath, packages: packageNames }) => { + if (!packageNames?.length) { + log.error('No connector name provided'); + } + + const existingPackages = await getConnectorPackagesFrom(inputPath); + const notFoundPackageNames = packageNames.filter( + (current) => !existingPackages.some(({ name }) => current === name) + ); + + if (notFoundPackageNames.length > 0) { + log.error( + `Cannot remove ${notFoundPackageNames + .map((name) => chalk.green(name)) + .join(', ')}: not found in your Logto instance directory` + ); + } + + const okSymbol = Symbol('Connector removed'); + const result = await Promise.all( + packageNames.map(async (current) => { + const packageInfo = existingPackages.find(({ name }) => name === current); + + try { + await fsExtra.remove(packageInfo?.path ?? ''); + + return okSymbol; + } catch (error: unknown) { + log.warn(`Error while removing ${chalk.green(packageInfo?.name)}`); + log.warn(error); + + return error; + } + }) + ); + const errorCount = result.filter((value) => value !== okSymbol).length; + + log.info(`Removed ${result.length - errorCount} connectors`); + }, +}; + +export default remove; diff --git a/packages/cli/src/commands/connector/utils.ts b/packages/cli/src/commands/connector/utils.ts new file mode 100644 index 000000000..1d1f2a476 --- /dev/null +++ b/packages/cli/src/commands/connector/utils.ts @@ -0,0 +1,224 @@ +import { exec } from 'child_process'; +import { existsSync } from 'fs'; +import { readFile, mkdir, unlink, readdir } from 'fs/promises'; +import path from 'path'; +import { promisify } from 'util'; + +import { assert, conditionalString } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { ensureDir, remove } from 'fs-extra'; +import inquirer from 'inquirer'; +import pRetry from 'p-retry'; +import tar from 'tar'; +import { z } from 'zod'; + +import { connectorDirectory } from '../../constants'; +import { isTty, log, oraPromise } from '../../utilities'; +import { defaultPath } from '../install/utils'; + +const coreDirectory = 'packages/core'; +const execPromise = promisify(exec); +export const npmPackResultGuard = z + .object({ + name: z.string(), + version: z.string(), + filename: z.string(), + }) + .array(); + +const buildPathErrorMessage = (value: string) => + `The path ${chalk.green(value)} does not contain a Logto instance, please try another.`; + +const validatePath = async (value: string) => { + const corePackageJsonPath = path.resolve(path.join(value, coreDirectory, 'package.json')); + + if (!existsSync(corePackageJsonPath)) { + return buildPathErrorMessage(value); + } + + const packageJson = await readFile(corePackageJsonPath, { encoding: 'utf8' }); + const packageName = await z + .object({ name: z.string() }) + .parseAsync(JSON.parse(packageJson)) + .then(({ name }) => name) + .catch(() => ''); + + if (packageName !== '@logto/core') { + return buildPathErrorMessage(value); + } + + return true; +}; + +export const inquireInstancePath = async (initialPath?: string) => { + const inquire = async () => { + if (!isTty()) { + assert(initialPath, new Error('Path is missing')); + + return initialPath; + } + + const { instancePath } = await inquirer.prompt<{ instancePath: string }>( + { + name: 'instancePath', + message: 'Where is your Logto instance?', + type: 'input', + default: defaultPath, + filter: (value: string) => value.trim(), + validate: validatePath, + }, + { instancePath: initialPath } + ); + + return instancePath; + }; + + const instancePath = await inquire(); + const validated = await validatePath(instancePath); + + if (validated !== true) { + log.error(validated); + } + + return instancePath; +}; + +const packagePrefix = 'connector-'; + +export const normalizePackageName = (name: string) => + name + .split('/') + // Prepend prefix to the last fragment if needed + .map((fragment, index, array) => + index === array.length - 1 && !fragment.startsWith(packagePrefix) && !fragment.startsWith('@') + ? packagePrefix + fragment + : fragment + ) + .join('/'); + +const getConnectorDirectory = (instancePath: string) => + path.join(instancePath, coreDirectory, connectorDirectory); + +export const isOfficialConnector = (packageName: string) => + packageName.startsWith('@logto/connector-'); + +const getConnectorPackageName = async (directory: string) => { + const filePath = path.join(directory, 'package.json'); + + if (!existsSync(filePath)) { + return; + } + + const json = await readFile(filePath, 'utf8'); + const { name } = z.object({ name: z.string() }).parse(JSON.parse(json)); + + if (name.startsWith('connector-') || Boolean(name.split('/')[1]?.startsWith('connector-'))) { + return name; + } +}; + +export type ConnectorPackage = { + name: string; + path: string; +}; + +export const getConnectorPackagesFrom = async (instancePath?: string) => { + const directory = getConnectorDirectory(await inquireInstancePath(instancePath)); + const content = await readdir(directory, 'utf8'); + const rawPackages = await Promise.all( + content.map(async (value) => { + const currentDirectory = path.join(directory, value); + + return { name: await getConnectorPackageName(currentDirectory), path: currentDirectory }; + }) + ); + + return rawPackages.filter( + (packageInfo): packageInfo is ConnectorPackage => typeof packageInfo.name === 'string' + ); +}; + +export const addConnectors = async (instancePath: string, packageNames: string[]) => { + const cwd = getConnectorDirectory(instancePath); + + if (!existsSync(cwd)) { + await mkdir(cwd); + } + + log.info('Fetch connector metadata'); + + const results = await Promise.all( + packageNames + .map((name) => normalizePackageName(name)) + .map(async (packageName) => { + const run = async () => { + const { stdout } = await execPromise(`npm pack ${packageName} --json`, { cwd }); + const result = npmPackResultGuard.parse(JSON.parse(stdout)); + + if (!result[0]) { + throw new Error( + `Unable to execute ${chalk.green('npm pack')} on package ${chalk.green(packageName)}` + ); + } + + const { filename, name } = result[0]; + const escapedFilename = filename.replace(/\//g, '-').replace(/@/g, ''); + const tarPath = path.join(cwd, escapedFilename); + const packageDirectory = path.join(cwd, name.replace(/\//g, '-')); + + await remove(packageDirectory); + await ensureDir(packageDirectory); + await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 }); + await unlink(tarPath); + + log.succeed(`Added ${chalk.green(name)}`); + }; + + try { + await pRetry(run, { retries: 2 }); + } catch (error: unknown) { + console.warn(`[${packageName}]`, error); + + return packageName; + } + }) + ); + + const errorPackages = results.filter(Boolean); + const errorCount = errorPackages.length; + + log.info( + errorCount + ? `Finished with ${errorCount} error${conditionalString(errorCount > 1 && 's')}.` + : 'Finished' + ); + + if (errorCount) { + log.warn('Failed to add ' + errorPackages.map((name) => chalk.green(name)).join(', ')); + } +}; + +const officialConnectorPrefix = '@logto/connector-'; + +const fetchOfficialConnectorList = async () => { + const { stdout } = await execPromise(`npm search ${officialConnectorPrefix} --json`); + const packages = z + .object({ name: z.string() }) + .transform(({ name }) => name) + .array() + .parse(JSON.parse(stdout)); + + return packages.filter((name) => + ['mock', 'kit'].every( + (excluded) => !name.slice(officialConnectorPrefix.length).startsWith(excluded) + ) + ); +}; + +export const addOfficialConnectors = async (instancePath: string) => { + const packages = await oraPromise(fetchOfficialConnectorList(), { + text: 'Fetch official connector list', + prefixText: chalk.blue('[info]'), + }); + await addConnectors(instancePath, packages); +}; diff --git a/packages/cli/src/commands/database/alteration/index.test.ts b/packages/cli/src/commands/database/alteration/index.test.ts new file mode 100644 index 000000000..8ea61e772 --- /dev/null +++ b/packages/cli/src/commands/database/alteration/index.test.ts @@ -0,0 +1,81 @@ +import { createMockPool } from 'slonik'; + +import * as functions from '.'; +import * as queries from '../../../queries/logto-config'; +import { QueryType } from '../../../test-utilities'; +import { chooseAlterationsByVersion } from './version'; + +const mockQuery: jest.MockedFunction = jest.fn(); + +const pool = createMockPool({ + query: async (sql, values) => { + return mockQuery(sql, values); + }, +}); + +describe('getUndeployedAlterations()', () => { + const files = Object.freeze([ + { filename: '1.0.0-1663923770-a.js', path: '/alterations-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); + + await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files); + }); + + it('returns files whose timestamp is greater then database timestamp', async () => { + jest + .spyOn(queries, 'getCurrentDatabaseAlterationTimestamp') + .mockResolvedValueOnce(1_663_923_770); + + await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]); + }); +}); + +describe('chooseAlterationsByVersion()', () => { + const files = Object.freeze( + [ + '1.0.0_beta.9-1663923770-a.js', + '1.0.0_beta.9-1663923771-b.js', + '1.0.0_beta.10-1663923772-c.js', + '1.0.0_beta.11-1663923773-c.js', + '1.0.0_beta.11-1663923774-c.js', + '1.0.0-1663923775-c.js', + '1.0.0-1663923776-c.js', + '1.0.1-1663923777-c.js', + '1.2.0-1663923778-c.js', + 'next-1663923778-c.js', + 'next-1663923779-c.js', + 'next-1663923780-c.js', + 'next1-1663923781-c.js', + ].map((filename) => ({ filename, path: '/alterations/' + filename })) + ); + + it('chooses nothing when input version is invalid', async () => { + await expect(chooseAlterationsByVersion(files, 'next1')).rejects.toThrow( + 'Invalid Version: next1' + ); + await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow('Invalid Version: ok'); + }); + + it('chooses correct alteration files', async () => { + await Promise.all([ + expect(chooseAlterationsByVersion([], 'v1.0.0')).resolves.toEqual([]), + expect(chooseAlterationsByVersion(files, 'v1.0.0')).resolves.toEqual(files.slice(0, 7)), + expect(chooseAlterationsByVersion(files, 'v1.0.0-beta.10')).resolves.toEqual( + files.slice(0, 3) + ), + expect(chooseAlterationsByVersion(files, 'v1.1.0')).resolves.toEqual(files.slice(0, 8)), + expect(chooseAlterationsByVersion(files, 'v1.2.0')).resolves.toEqual(files.slice(0, 9)), + expect(chooseAlterationsByVersion(files, 'next')).resolves.toEqual(files.slice(0, 12)), + ]); + }); +}); diff --git a/packages/cli/src/commands/database/alteration/index.ts b/packages/cli/src/commands/database/alteration/index.ts new file mode 100644 index 000000000..5055e013d --- /dev/null +++ b/packages/cli/src/commands/database/alteration/index.ts @@ -0,0 +1,163 @@ +import path from 'path'; + +import { AlterationScript } from '@logto/schemas/lib/types/alteration'; +import { findPackage } from '@logto/shared'; +import { conditionalString } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { copy, existsSync, remove, readdir } from 'fs-extra'; +import { DatabasePool } from 'slonik'; +import { CommandModule } from 'yargs'; + +import { createPoolFromConfig } from '../../../database'; +import { + getCurrentDatabaseAlterationTimestamp, + updateDatabaseTimestamp, +} from '../../../queries/logto-config'; +import { getPathInModule, log } from '../../../utilities'; +import { 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]); +}; + +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; +}; + +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( + // 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]; + + 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 [target]', 'alt', 'alter'], + describe: 'Perform database alteration', + builder: (yargs) => + yargs + .positional('action', { + describe: 'The action to perform, now it only accepts `deploy`', + type: 'string', + demandOption: true, + }) + .positional('target', { + describe: 'The target Logto version for alteration', + type: 'string', + }), + handler: async ({ action, target }) => { + if (action !== 'deploy') { + log.error('Unsupported action'); + } + + const pool = await createPoolFromConfig(); + const alterations = await chooseAlterationsByVersion( + await getUndeployedAlterations(pool), + target + ); + + 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/alteration/type.ts b/packages/cli/src/commands/database/alteration/type.ts new file mode 100644 index 000000000..dc94e658e --- /dev/null +++ b/packages/cli/src/commands/database/alteration/type.ts @@ -0,0 +1 @@ +export type AlterationFile = { path: string; filename: string }; diff --git a/packages/cli/src/commands/database/alteration/version.ts b/packages/cli/src/commands/database/alteration/version.ts new file mode 100644 index 000000000..ffc0b38ae --- /dev/null +++ b/packages/cli/src/commands/database/alteration/version.ts @@ -0,0 +1,92 @@ +import { assert, conditional } from '@silverhand/essentials'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { SemVer, compare, eq, gt } from 'semver'; + +import { findLastIndex, isTty, log } from '../../../utilities'; +import { AlterationFile } from './type'; + +const getVersionFromFilename = (filename: string) => { + try { + return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown'); + } catch {} +}; + +const latestTag = 'latest'; +const nextTag = 'next'; + +export const chooseAlterationsByVersion = async ( + alterations: readonly AlterationFile[], + initialVersion?: string +) => { + if (initialVersion === nextTag) { + const endIndex = findLastIndex( + alterations, + ({ filename }) => + filename.startsWith(nextTag + '-') || Boolean(getVersionFromFilename(filename)) + ); + + if (endIndex === -1) { + return []; + } + + log.info(`Deploy target ${chalk.green(nextTag)}`); + + return alterations.slice(0, endIndex + 1); + } + + const versions = alterations + .map(({ filename }) => getVersionFromFilename(filename)) + .filter((version): version is SemVer => version instanceof SemVer) + // Cannot use `Set` to deduplicate since it's a class + .filter((version, index, self) => index === self.findIndex((another) => eq(version, another))) + .slice() + .sort((i, j) => compare(j, i)); + const initialSemVersion = conditional( + initialVersion && initialVersion !== latestTag && new SemVer(initialVersion) + ); + const firstVersion = versions[0]; + + if (!firstVersion) { + return []; + } + + const getTargetVersion = async () => { + if (initialVersion === latestTag) { + return firstVersion; + } + + if (!isTty()) { + assert(initialSemVersion, new Error('Missing target version')); + + return initialSemVersion; + } + + const { version } = await inquirer.prompt<{ version: SemVer }>( + { + type: 'list', + message: 'Choose the alteration target version', + name: 'version', + choices: versions.map((semVersion) => ({ + name: semVersion.version, + value: semVersion, + })), + }, + { + version: initialSemVersion, + } + ); + + return version; + }; + + const targetVersion = await getTargetVersion(); + + log.info(`Deploy target ${chalk.green(targetVersion.version)}`); + + return alterations.filter(({ filename }) => { + const version = getVersionFromFilename(filename); + + return version && !gt(version, targetVersion); + }); +}; diff --git a/packages/cli/src/commands/database/config.ts b/packages/cli/src/commands/database/config.ts new file mode 100644 index 000000000..c000afd79 --- /dev/null +++ b/packages/cli/src/commands/database/config.ts @@ -0,0 +1,106 @@ +import { logtoConfigGuards, LogtoConfigKey, logtoConfigKeys } from '@logto/schemas'; +import { deduplicate, noop } from '@silverhand/essentials'; +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { createPoolFromConfig } from '../../database'; +import { getRowsByKeys, updateValueByKey } from '../../queries/logto-config'; +import { 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}` + ); + } +}; + +const getConfig: CommandModule = { + command: 'get [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 createPoolFromConfig(); + 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') + ); + }, +}; + +const setConfig: CommandModule = { + command: 'set ', + 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 createPoolFromConfig(); + await updateValueByKey(pool, key, guarded); + await pool.end(); + + log.info(`Update ${chalk.green(key)} succeeded`); + }, +}; + +const config: CommandModule = { + command: ['config', 'configs'], + describe: 'Commands for Logto database config', + builder: (yargs) => yargs.command(getConfig).command(setConfig).demandCommand(1), + handler: noop, +}; + +export default config; diff --git a/packages/cli/src/commands/database/index.ts b/packages/cli/src/commands/database/index.ts new file mode 100644 index 000000000..226da93f9 --- /dev/null +++ b/packages/cli/src/commands/database/index.ts @@ -0,0 +1,15 @@ +import { noop } from '@silverhand/essentials'; +import { CommandModule } from 'yargs'; + +import alteration from './alteration'; +import config from './config'; +import seed from './seed'; + +const database: CommandModule = { + command: ['database', 'db'], + describe: 'Commands for Logto database', + builder: (yargs) => yargs.command(config).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..4dbc863c1 --- /dev/null +++ b/packages/cli/src/commands/database/seed/index.ts @@ -0,0 +1,174 @@ +import { readdir, readFile } from 'fs/promises'; +import path from 'path'; + +import { logtoConfigGuards, LogtoOidcConfigKey, seeds } from '@logto/schemas'; +import { buildApplicationSecret } from '@logto/shared'; +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, + doesConfigsTableExist, + updateDatabaseTimestamp, + updateValueByKey, +} from '../../../queries/logto-config'; +import { 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; swe?: boolean }> = { + command: 'seed [type]', + describe: 'Create database then seed tables and data', + builder: (yargs) => + yargs + .option('swe', { + describe: 'Skip the seeding process when Logto configs table exists', + alias: 'skip-when-exists', + type: 'boolean', + }) + .positional('type', { + describe: 'Optional seed type', + type: 'string', + choices: seedChoices, + default: 'all', + }), + handler: async ({ type, swe }) => { + const pool = await createPoolAndDatabaseIfNeeded(); + + if (swe && (await doesConfigsTableExist(pool))) { + log.info('Seeding skipped'); + await pool.end(); + + return; + } + + 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/index.ts b/packages/cli/src/commands/install/index.ts new file mode 100644 index 000000000..b0cb27fd3 --- /dev/null +++ b/packages/cli/src/commands/install/index.ts @@ -0,0 +1,110 @@ +import chalk from 'chalk'; +import { CommandModule } from 'yargs'; + +import { getDatabaseUrlFromConfig } from '../../database'; +import { log } from '../../utilities'; +import { addOfficialConnectors } from '../connector/utils'; +import { + validateNodeVersion, + inquireInstancePath, + validateDatabase, + downloadRelease, + seedDatabase, + createEnv, + logFinale, + decompress, + inquireOfficialConnectors, + isUrl, +} from './utils'; + +export type InstallArgs = { + path?: string; + skipSeed: boolean; + officialConnectors?: boolean; + downloadUrl?: string; +}; + +const installLogto = async ({ path, skipSeed, officialConnectors, downloadUrl }: InstallArgs) => { + validateNodeVersion(); + + // Get instance path + const instancePath = await inquireInstancePath(path); + + // Validate if user has a valid database + await validateDatabase(); + + // Download and decompress + const tarPath = + !downloadUrl || isUrl(downloadUrl) ? await downloadRelease(downloadUrl) : downloadUrl; + await decompress(instancePath, tarPath); + + // Seed database + if (skipSeed) { + log.info( + `Skipped database seeding.\n\n' + ' You can use the ${chalk.green( + 'db seed' + )} command to seed database when ready.\n` + ); + } else { + await seedDatabase(instancePath); + } + + // Save to dot env + await createEnv(instancePath, await getDatabaseUrlFromConfig()); + + // Add official connectors + if (await inquireOfficialConnectors(officialConnectors)) { + await addOfficialConnectors(instancePath); + } else { + log.info( + 'Skipped adding official connectors.\n\n' + + ` You can use the ${chalk.green('connector add')} command to add connectors at any time.\n` + ); + } + + // Finale + logFinale(instancePath); +}; + +const install: CommandModule< + unknown, + { + p?: string; + ss: boolean; + oc?: boolean; + du?: string; + } +> = { + command: ['init', 'i', 'install'], + describe: 'Download and run the latest Logto release', + builder: (yargs) => + yargs.options({ + p: { + alias: 'path', + describe: 'Path of Logto, must be a non-existing path', + type: 'string', + }, + ss: { + alias: 'skip-seed', + describe: 'Skip Logto database seeding', + type: 'boolean', + default: false, + }, + oc: { + alias: 'official-connectors', + describe: 'Add official connectors after downloading Logto', + type: 'boolean', + }, + du: { + alias: 'download-url', + describe: 'URL for downloading Logto, can be a local path to tar', + type: 'string', + hidden: true, + }, + }), + handler: async ({ p, ss, oc, du }) => { + await installLogto({ path: p, skipSeed: ss, officialConnectors: oc, downloadUrl: du }); + }, +}; + +export default install; diff --git a/packages/cli/src/commands/install/utils.ts b/packages/cli/src/commands/install/utils.ts new file mode 100644 index 000000000..e21d7ee0e --- /dev/null +++ b/packages/cli/src/commands/install/utils.ts @@ -0,0 +1,195 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { mkdir } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { assert } 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 { createPoolAndDatabaseIfNeeded } from '../../database'; +import { + cliConfig, + ConfigKey, + downloadFile, + isTty, + log, + oraPromise, + safeExecSync, +} from '../../utilities'; +import { seedByPool } from '../database/seed'; + +export const defaultPath = path.join(os.homedir(), 'logto'); +const pgRequired = new semver.SemVer('14.0.0'); + +export 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 validatePath = (value: string) => + existsSync(path.resolve(value)) + ? `The path ${chalk.green(value)} already exists, please try another.` + : true; + +export const inquireInstancePath = async (initialPath?: string) => { + if (!isTty()) { + assert(initialPath, new Error('Path is missing')); + + return initialPath; + } + + 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: validatePath, + }, + { instancePath: initialPath } + ); + + // Validate for initialPath + const validated = validatePath(instancePath); + + if (validated !== true) { + log.error(validated); + } + + return instancePath; +}; + +export const validateDatabase = async () => { + if (cliConfig.has(ConfigKey.DatabaseUrl) || !isTty()) { + return; + } + + 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.'); + } +}; + +export const downloadRelease = async (url?: string) => { + const tarFilePath = path.resolve(os.tmpdir(), './logto.tar.gz'); + + log.info(`Download Logto to ${tarFilePath}`); + await downloadFile( + url ?? 'https://github.com/logto-io/logto/releases/latest/download/logto.tar.gz', + tarFilePath + ); + + return tarFilePath; +}; + +export const decompress = async (toPath: string, tarPath: string) => { + const run = async () => { + try { + await mkdir(toPath); + await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); + } catch (error: unknown) { + log.error(error); + } + }; + + return oraPromise( + run(), + { + text: `Decompress to ${toPath}`, + prefixText: chalk.blue('[info]'), + }, + true + ); +}; + +export const seedDatabase = async (instancePath: string) => { + try { + const pool = await createPoolAndDatabaseIfNeeded(); + await seedByPool(pool, 'all'); + await pool.end(); + } catch (error: unknown) { + console.error(error); + + await oraPromise(remove(instancePath), { + text: 'Clean up', + prefixText: chalk.blue('[info]'), + }); + + log.error( + 'Error occurred during seeding your Logto database. Nothing has changed since the seeding process was in a transaction.\n\n' + + ` To skip the database seeding, append ${chalk.green( + '--skip-seed' + )} to the command options.` + ); + } +}; + +export const createEnv = async (instancePath: string, databaseUrl: string) => { + const dotEnvPath = path.resolve(instancePath, '.env'); + await writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, { + encoding: 'utf8', + }); + log.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`); +}; + +export const logFinale = (instancePath: string) => { + const startCommand = `cd ${instancePath} && npm start`; + log.info( + `Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}` + ); +}; + +export const inquireOfficialConnectors = async (initialAnswer?: boolean) => { + const { value } = await inquirer.prompt<{ value: boolean }>( + { + name: 'value', + message: 'Do you want to add official connectors?', + type: 'confirm', + default: true, + }, + { value: initialAnswer } + ); + + return value; +}; + +export const isUrl = (string: string) => { + try { + // On purpose to test + // eslint-disable-next-line no-new + new URL(string); + + return true; + } catch { + return false; + } +}; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 000000000..a759ff0c4 --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1 @@ +export const connectorDirectory = 'connectors'; diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts new file mode 100644 index 000000000..e3dcc4843 --- /dev/null +++ b/packages/cli/src/database.ts @@ -0,0 +1,84 @@ +import { SchemaLike } from '@logto/schemas'; +import { convertToPrimitiveOrSql } from '@logto/shared'; +import { assert } from '@silverhand/essentials'; +import decamelize from 'decamelize'; +import { createPool, parseDsn, sql, stringifyDsn } from 'slonik'; +import { createInterceptors } from 'slonik-interceptor-preset'; +import { z } from 'zod'; + +import { ConfigKey, getCliConfigWithPrompt, log } from './utilities'; + +export const defaultDatabaseUrl = 'postgresql://localhost:5432/logto'; + +export const getDatabaseUrlFromConfig = async () => + (await getCliConfigWithPrompt({ + key: ConfigKey.DatabaseUrl, + readableKey: 'Logto database URL', + defaultValue: defaultDatabaseUrl, + })) ?? ''; + +export const createPoolFromConfig = async () => { + const databaseUrl = await getDatabaseUrlFromConfig(); + assert(parseDsn(databaseUrl).databaseName, new Error('Database name is required in URL')); + + return createPool(databaseUrl, { + interceptors: createInterceptors(), + }); +}; + +/** + * Create a database pool with the URL in CLI config; if no URL found, prompt to input. + * 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 createPoolFromConfig(); + } 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 getDatabaseUrlFromConfig(); + 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' }), { + interceptors: createInterceptors(), + }); + await maintenancePool.query(sql` + create database ${sql.identifier([databaseName])} + with + encoding = 'UTF8' + connection_limit = -1; + `); + await maintenancePool.end(); + + log.succeed(`Created database ${databaseName}`); + + return createPoolFromConfig(); + } +}; + +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 index 1f3f6f4f9..e0596c9e4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,130 +1,55 @@ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { mkdir } from 'fs/promises'; -import os from 'os'; -import path from 'path'; - import chalk from 'chalk'; -import ora from 'ora'; -import * as prompts from 'prompts'; -import * as semver from 'semver'; -import tar from 'tar'; +import dotenv from 'dotenv'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; -import { downloadFile, log, safeExecSync } from './utilities'; +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'; -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 getInstancePath = async () => { - const response = await prompts.default( - [ - { - name: 'instancePath', - message: 'Where should we create your logto instance?', - type: 'text', - initial: './logto', - format: (value: string) => path.resolve(value.trim()), - validate: (value: string) => - existsSync(value) ? 'That path already exists, please try another.' : true, - }, - { - 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: () => { - 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) && 'confirm'; - }, - format: (previous) => { - if (!previous) { - log.error('Logto requires a Postgres instance to run.'); - } - }, - }, - ], - { - onCancel: () => { - log.error('Operation cancelled'); - }, +void yargs(hideBin(process.argv)) + .version(false) + .option('env', { + alias: ['e', 'env-file'], + describe: 'The path to your `.env` file', + type: 'string', + }) + .option('db', { + alias: ['db-url', 'database-url'], + describe: 'The Postgres URL to Logto database', + type: 'string', + }) + .option('version', { + alias: 'v', + describe: 'Print Logto CLI version', + type: 'boolean', + global: false, + }) + .middleware(({ version }) => { + if (version) { + console.log(packageJson.name + ' v' + packageJson.version); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); } - ); + }, true) + .middleware(({ env, db: databaseUrl }) => { + dotenv.config({ path: env }); - return String(response.instancePath); -}; + const initialDatabaseUrl = databaseUrl ?? process.env[ConfigKey.DatabaseUrl]; -const tryStartInstance = async (instancePath: string) => { - const response = await prompts.default({ - name: 'startInstance', - message: 'Would you like to start Logto now?', - type: 'confirm', - initial: true, - }); - - const yes = Boolean(response.startInstance); - const startCommand = `cd ${instancePath} && npm start`; - - if (yes) { - execSync(startCommand, { stdio: 'inherit' }); - } else { - log.info(`You can use ${startCommand} to start Logto. Happy hacking!`); - } -}; - -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) => { - const decompressSpinner = ora({ - text: `Decompress to ${toPath}`, - prefixText: chalk.blue('[info]'), - }).start(); - - try { - await mkdir(toPath); - await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); - } catch { - decompressSpinner.fail(); - - return; - } - - decompressSpinner.succeed(); -}; - -const main = async () => { - validateNodeVersion(); - - const instancePath = await getInstancePath(); - const tarPath = await downloadRelease(); - - await decompress(instancePath, tarPath); - await tryStartInstance(instancePath); -}; - -void main(); + if (initialDatabaseUrl) { + cliConfig.set(ConfigKey.DatabaseUrl, initialDatabaseUrl); + } + }) + .command(install) + .command(database) + .command(connector) + .demandCommand(1) + .showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`) + .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..2a407122d --- /dev/null +++ b/packages/cli/src/queries/logto-config.test.ts @@ -0,0 +1,102 @@ +import { AlterationStateKey, LogtoConfigs } from '@logto/schemas'; +import { convertToIdentifiers } from '@logto/shared'; +import { createMockPool, createMockQueryResult, sql } from 'slonik'; + +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..1b1599c56 --- /dev/null +++ b/packages/cli/src/queries/logto-config.ts @@ -0,0 +1,77 @@ +import { + AlterationState, + LogtoConfig, + logtoConfigGuards, + LogtoConfigKey, + LogtoConfigs, + AlterationStateKey, +} from '@logto/schemas'; +import { convertToIdentifiers } from '@logto/shared'; +import { Nullable } from '@silverhand/essentials'; +import { DatabasePool, DatabaseTransactionConnection, sql } from 'slonik'; +import { z } from 'zod'; + +const { table, fields } = convertToIdentifiers(LogtoConfigs); + +export const doesConfigsTableExist = async (pool: DatabasePool) => { + const { rows } = await pool.query<{ regclass: Nullable }>( + sql`select to_regclass(${LogtoConfigs.table}) as regclass` + ); + + return Boolean(rows[0]?.regclass); +}; + +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 index f5e60060d..d6196c408 100644 --- a/packages/cli/src/utilities.ts +++ b/packages/cli/src/utilities.ts @@ -1,9 +1,12 @@ 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 ora from 'ora'; export const safeExecSync = (command: string) => { @@ -14,19 +17,23 @@ export const safeExecSync = (command: string) => { type Log = Readonly<{ info: typeof console.log; + succeed: typeof console.log; warn: typeof console.log; - error: 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.log(chalk.yellow('[warn]'), ...args); + console.warn(chalk.yellow('[warn]'), ...args); }, error: (...args) => { - console.log(chalk.red('[error]'), ...args); + console.error(chalk.red('[error]'), ...args); // eslint-disable-next-line unicorn/no-process-exit process.exit(1); }, @@ -68,3 +75,99 @@ 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`)), + 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; + } +}; + +export const isTty = () => process.stdin.isTTY; + +export enum ConfigKey { + DatabaseUrl = 'DB_URL', +} + +export const cliConfig = new Map>(); + +export type GetCliConfigWithPrompt = { + key: ConfigKey; + readableKey: string; + comments?: string; + defaultValue?: string; +}; + +export const getCliConfigWithPrompt = async ({ + key, + readableKey, + comments, + defaultValue, +}: GetCliConfigWithPrompt) => { + if (cliConfig.has(key) || !isTty()) { + return cliConfig.get(key); + } + + const { input } = await inquirer.prompt<{ input?: string }>({ + type: 'input', + name: 'input', + message: `Enter your ${readableKey}${conditionalString(comments && ' ' + comments)}`, + default: defaultValue, + }); + + cliConfig.set(key, input); + + return input; +}; + +// https://stackoverflow.com/a/53187807/12514940 +/** + * Returns the index of the last element in the array where predicate is true, and -1 + * otherwise. + * @param array The source array to search in + * @param predicate find calls predicate once for each element of the array, in descending + * order, until it finds one where predicate returns true. If such an element is found, + * findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1. + */ +export function findLastIndex( + array: readonly T[], + predicate: (value: T, index: number, object: readonly T[]) => boolean +): number { + // eslint-disable-next-line @silverhand/fp/no-let + let { length } = array; + + // eslint-disable-next-line @silverhand/fp/no-mutation + while (length--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (predicate(array[length]!, length, array)) { + return length; + } + } + + return -1; +} 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 index 747c9b09d..ef675dbdc 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,9 +4,12 @@ "outDir": "lib", "declaration": true, "module": "node16", - "target": "es2022" + "target": "es2022", + "types": ["node", "jest"] }, "include": [ - "src" - ] + "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 98f3a636e..996349918 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -3,6 +3,43 @@ 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.12](https://github.com/logto-io/logto/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2022-10-19) + +**Note:** Version bump only for package @logto/console + + + + + +## [1.0.0-beta.11](https://github.com/logto-io/logto/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2022-10-19) + + +### Features + +* add vertical center support ([#2032](https://github.com/logto-io/logto/issues/2032)) ([5eeb06e](https://github.com/logto-io/logto/commit/5eeb06e301b06d3caad65ece1b7b05cf6e160dd4)) +* **console:** add a11y lint to ac ([#2066](https://github.com/logto-io/logto/issues/2066)) ([37d2b0c](https://github.com/logto-io/logto/commit/37d2b0ce5c09658d5e49be84b891d9a0d83f6f5c)) +* **console:** add custom language ([#2029](https://github.com/logto-io/logto/issues/2029)) ([800ac7f](https://github.com/logto-io/logto/commit/800ac7fcd9592875df29d897e3a704fc6a73fee1)) +* **console:** auto detect language setting ([#1941](https://github.com/logto-io/logto/issues/1941)) ([cdfaf8b](https://github.com/logto-io/logto/commit/cdfaf8b1c7fd268f205e4679cfc762d7e3eedfea)) +* **console:** delete custom phrases ([#2065](https://github.com/logto-io/logto/issues/2065)) ([68e8884](https://github.com/logto-io/logto/commit/68e88840bfe4f50682c028188f32bc2480e8d8d7)) +* **console:** display unsaved alert on custom phrases changed ([#1994](https://github.com/logto-io/logto/issues/1994)) ([0679a6a](https://github.com/logto-io/logto/commit/0679a6a67c71203e0bae3489768184a6e564937d)) +* **console:** manage language ([#1981](https://github.com/logto-io/logto/issues/1981)) ([48832e5](https://github.com/logto-io/logto/commit/48832e50548421b876deaf10b1d3379674e7f562)) + + +### Bug Fixes + +* add redirectURI validation on frontend & backend ([#1874](https://github.com/logto-io/logto/issues/1874)) ([4b0970b](https://github.com/logto-io/logto/commit/4b0970b6d8c6647a6e68bf27fe3db3aeb635768e)) +* **console:** checkbox styles ([7c85e50](https://github.com/logto-io/logto/commit/7c85e50c4597f6ed0a19384916ea6ef1bb3974a5)) +* **console:** clear select state on close modal ([#2071](https://github.com/logto-io/logto/issues/2071)) ([b6b9d7c](https://github.com/logto-io/logto/commit/b6b9d7ce80aefe7341b3167e18ce4af291052015)) +* **console:** language editor form should be dirty on clear button clicked ([#2037](https://github.com/logto-io/logto/issues/2037)) ([1223d23](https://github.com/logto-io/logto/commit/1223d23eb3f13cce707f6cd5eecd043c476f3514)) +* **console:** remove connector id and prevent text overflow ([#2072](https://github.com/logto-io/logto/issues/2072)) ([05b5025](https://github.com/logto-io/logto/commit/05b50250a387635649614aaeeec9757e7034a19d)) +* **console:** responsive modal items layout ([#2160](https://github.com/logto-io/logto/issues/2160)) ([ac38a7f](https://github.com/logto-io/logto/commit/ac38a7f3ac13b90ffb2ea8a94d40a390d652a62b)) +* **console:** save generated password in session storage ([#2116](https://github.com/logto-io/logto/issues/2116)) ([8a7f875](https://github.com/logto-io/logto/commit/8a7f875767f5d70edc41509ddd1973b87ad16ee9)) +* **console:** set undefined value to empty string in custom phrases ([#2074](https://github.com/logto-io/logto/issues/2074)) ([81f9fbc](https://github.com/logto-io/logto/commit/81f9fbc48379afc7de5d84e3614097ee37a1424b)) +* **console:** show correct password after reset ([#2063](https://github.com/logto-io/logto/issues/2063)) ([02c082c](https://github.com/logto-io/logto/commit/02c082cb71258a931925df87126060fa9d9a2c5d)) +* **console:** use fallback language in preview ([#1960](https://github.com/logto-io/logto/issues/1960)) ([f25ae4d](https://github.com/logto-io/logto/commit/f25ae4de1477feca5a8e077cb05146bb13719e6f)) + + + ## [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) diff --git a/packages/console/package.json b/packages/console/package.json index 0a001affb..26cd43c15 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@logto/console", - "version": "1.0.0-beta.10", + "version": "1.0.0-beta.12", "description": "> TODO: description", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", @@ -18,21 +18,22 @@ }, "devDependencies": { "@fontsource/roboto-mono": "^4.5.7", - "@logto/core-kit": "^1.0.0-beta.13", - "@logto/phrases": "^1.0.0-beta.10", - "@logto/phrases-ui": "^1.0.0-beta.10", + "@logto/core-kit": "1.0.0-beta.18", + "@logto/language-kit": "1.0.0-beta.19", + "@logto/phrases": "^1.0.0-beta.12", + "@logto/phrases-ui": "^1.0.0-beta.12", "@logto/react": "1.0.0-beta.8", - "@logto/schemas": "^1.0.0-beta.10", + "@logto/schemas": "^1.0.0-beta.12", "@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/essentials": "^1.2.1", - "@silverhand/ts-config": "1.0.0", - "@silverhand/ts-config-react": "1.0.0", + "@silverhand/eslint-config": "1.2.0", + "@silverhand/eslint-config-react": "1.2.1", + "@silverhand/essentials": "^1.3.0", + "@silverhand/ts-config": "1.2.1", + "@silverhand/ts-config-react": "1.2.1", "@tsconfig/docusaurus": "^1.0.5", "@types/color": "^3.0.3", "@types/lodash.kebabcase": "^4.1.6", @@ -43,9 +44,11 @@ "@types/react-modal": "^3.13.1", "@types/react-syntax-highlighter": "^15.5.1", "classnames": "^2.3.1", + "clean-deep": "^3.4.0", "cross-env": "^7.0.3", "csstype": "^3.0.11", "dayjs": "^1.10.5", + "deepmerge": "^4.2.2", "dnd-core": "^16.0.0", "eslint": "^8.21.0", "history": "^5.3.0", @@ -89,8 +92,7 @@ "eslintConfig": { "extends": "@silverhand/react", "rules": { - "complexity": "off", - "@typescript-eslint/prefer-nullish-coalescing": "off" + "complexity": "off" } }, "stylelint": { diff --git a/packages/console/src/assets/images/arrow-down.svg b/packages/console/src/assets/images/arrow-down.svg new file mode 100644 index 000000000..c91bc0bba --- /dev/null +++ b/packages/console/src/assets/images/arrow-down.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/arrow-up.svg b/packages/console/src/assets/images/arrow-up.svg new file mode 100644 index 000000000..f26a261fb --- /dev/null +++ b/packages/console/src/assets/images/arrow-up.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/back.svg b/packages/console/src/assets/images/back.svg new file mode 100644 index 000000000..a0585cb15 --- /dev/null +++ b/packages/console/src/assets/images/back.svg @@ -0,0 +1,4 @@ + + + diff --git a/packages/console/src/assets/images/check-box-selected-dark.svg b/packages/console/src/assets/images/check-box-selected-dark.svg new file mode 100644 index 000000000..462af9a6c --- /dev/null +++ b/packages/console/src/assets/images/check-box-selected-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/console/src/assets/images/check-box-selected-disabled-dark.svg b/packages/console/src/assets/images/check-box-selected-disabled-dark.svg new file mode 100644 index 000000000..e910d5932 --- /dev/null +++ b/packages/console/src/assets/images/check-box-selected-disabled-dark.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/console/src/assets/images/check-box-selected-disabled.svg b/packages/console/src/assets/images/check-box-selected-disabled.svg new file mode 100644 index 000000000..ec185beb8 --- /dev/null +++ b/packages/console/src/assets/images/check-box-selected-disabled.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/console/src/assets/images/check-box-selected.svg b/packages/console/src/assets/images/check-box-selected.svg new file mode 100644 index 000000000..b74093574 --- /dev/null +++ b/packages/console/src/assets/images/check-box-selected.svg @@ -0,0 +1,6 @@ + + + + diff --git a/packages/console/src/assets/images/check-box-unselected-dark.svg b/packages/console/src/assets/images/check-box-unselected-dark.svg new file mode 100644 index 000000000..3072054cd --- /dev/null +++ b/packages/console/src/assets/images/check-box-unselected-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/assets/images/check-box-unselected-disabled-dark.svg b/packages/console/src/assets/images/check-box-unselected-disabled-dark.svg new file mode 100644 index 000000000..773f24303 --- /dev/null +++ b/packages/console/src/assets/images/check-box-unselected-disabled-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/console/src/assets/images/check-box-unselected-disabled.svg b/packages/console/src/assets/images/check-box-unselected-disabled.svg new file mode 100644 index 000000000..7df385ca1 --- /dev/null +++ b/packages/console/src/assets/images/check-box-unselected-disabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/console/src/assets/images/check-box-unselected.svg b/packages/console/src/assets/images/check-box-unselected.svg new file mode 100644 index 000000000..8d961e421 --- /dev/null +++ b/packages/console/src/assets/images/check-box-unselected.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/assets/images/circle-minus.svg b/packages/console/src/assets/images/circle-minus.svg new file mode 100644 index 000000000..c0beab55a --- /dev/null +++ b/packages/console/src/assets/images/circle-minus.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/circle-plus.svg b/packages/console/src/assets/images/circle-plus.svg new file mode 100644 index 000000000..1d2315bc1 --- /dev/null +++ b/packages/console/src/assets/images/circle-plus.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/clear.svg b/packages/console/src/assets/images/clear.svg new file mode 100644 index 000000000..f935ea9ec --- /dev/null +++ b/packages/console/src/assets/images/clear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/console/src/assets/images/close.svg b/packages/console/src/assets/images/close.svg new file mode 100644 index 000000000..8311c5ecf --- /dev/null +++ b/packages/console/src/assets/images/close.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/connector-platform-icon-native.svg b/packages/console/src/assets/images/connector-platform-icon-native.svg new file mode 100644 index 000000000..673450871 --- /dev/null +++ b/packages/console/src/assets/images/connector-platform-icon-native.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/connector-platform-icon-universal.svg b/packages/console/src/assets/images/connector-platform-icon-universal.svg new file mode 100644 index 000000000..5a16bd3e8 --- /dev/null +++ b/packages/console/src/assets/images/connector-platform-icon-universal.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/connector-platform-icon-web.svg b/packages/console/src/assets/images/connector-platform-icon-web.svg new file mode 100644 index 000000000..c907d3354 --- /dev/null +++ b/packages/console/src/assets/images/connector-platform-icon-web.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/copy.svg b/packages/console/src/assets/images/copy.svg new file mode 100644 index 000000000..88abbf65e --- /dev/null +++ b/packages/console/src/assets/images/copy.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/delete.svg b/packages/console/src/assets/images/delete.svg new file mode 100644 index 000000000..edd960596 --- /dev/null +++ b/packages/console/src/assets/images/delete.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/draggable.svg b/packages/console/src/assets/images/draggable.svg new file mode 100644 index 000000000..58e008743 --- /dev/null +++ b/packages/console/src/assets/images/draggable.svg @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/packages/console/src/assets/images/eye-closed.svg b/packages/console/src/assets/images/eye-closed.svg new file mode 100644 index 000000000..000cd9a25 --- /dev/null +++ b/packages/console/src/assets/images/eye-closed.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/eye.svg b/packages/console/src/assets/images/eye.svg new file mode 100644 index 000000000..d0bebbfd4 --- /dev/null +++ b/packages/console/src/assets/images/eye.svg @@ -0,0 +1,8 @@ + + + + diff --git a/packages/console/src/assets/images/failed.svg b/packages/console/src/assets/images/failed.svg new file mode 100644 index 000000000..e63b99a6e --- /dev/null +++ b/packages/console/src/assets/images/failed.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/info.svg b/packages/console/src/assets/images/info.svg new file mode 100644 index 000000000..6df9efe1a --- /dev/null +++ b/packages/console/src/assets/images/info.svg @@ -0,0 +1,11 @@ + + + + + diff --git a/packages/console/src/assets/images/keyboard-arrow-down.svg b/packages/console/src/assets/images/keyboard-arrow-down.svg new file mode 100644 index 000000000..7ec0838ba --- /dev/null +++ b/packages/console/src/assets/images/keyboard-arrow-down.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/keyboard-arrow-up.svg b/packages/console/src/assets/images/keyboard-arrow-up.svg new file mode 100644 index 000000000..52e5b1547 --- /dev/null +++ b/packages/console/src/assets/images/keyboard-arrow-up.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/logo.svg b/packages/console/src/assets/images/logo.svg new file mode 100644 index 000000000..9bc40cdfd --- /dev/null +++ b/packages/console/src/assets/images/logo.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/assets/images/minus.svg b/packages/console/src/assets/images/minus.svg new file mode 100644 index 000000000..6176fc2b3 --- /dev/null +++ b/packages/console/src/assets/images/minus.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/more.svg b/packages/console/src/assets/images/more.svg new file mode 100644 index 000000000..4e0be9362 --- /dev/null +++ b/packages/console/src/assets/images/more.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/phone-info.svg b/packages/console/src/assets/images/phone-info.svg new file mode 100644 index 000000000..5d5da2158 --- /dev/null +++ b/packages/console/src/assets/images/phone-info.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/packages/console/src/assets/images/plus.svg b/packages/console/src/assets/images/plus.svg new file mode 100644 index 000000000..e57837787 --- /dev/null +++ b/packages/console/src/assets/images/plus.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/reset.svg b/packages/console/src/assets/images/reset.svg new file mode 100644 index 000000000..bc8063383 --- /dev/null +++ b/packages/console/src/assets/images/reset.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/search.svg b/packages/console/src/assets/images/search.svg new file mode 100644 index 000000000..d4561c4bf --- /dev/null +++ b/packages/console/src/assets/images/search.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/sign-out.svg b/packages/console/src/assets/images/sign-out.svg new file mode 100644 index 000000000..162b86bb0 --- /dev/null +++ b/packages/console/src/assets/images/sign-out.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/success.svg b/packages/console/src/assets/images/success.svg new file mode 100644 index 000000000..647657505 --- /dev/null +++ b/packages/console/src/assets/images/success.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/tick.svg b/packages/console/src/assets/images/tick.svg new file mode 100644 index 000000000..875384e88 --- /dev/null +++ b/packages/console/src/assets/images/tick.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/assets/images/tip.svg b/packages/console/src/assets/images/tip.svg new file mode 100644 index 000000000..01c6eb22a --- /dev/null +++ b/packages/console/src/assets/images/tip.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/console/src/components/Alert/index.module.scss b/packages/console/src/components/Alert/index.module.scss index 8036346ea..5c5e0bffd 100644 --- a/packages/console/src/components/Alert/index.module.scss +++ b/packages/console/src/components/Alert/index.module.scss @@ -16,7 +16,7 @@ .icon { width: 20px; height: 20px; - color: var(--color-icon); + color: var(--color-text-secondary); } .content { diff --git a/packages/console/src/components/Alert/index.tsx b/packages/console/src/components/Alert/index.tsx index ae71ed2c3..ac32821bb 100644 --- a/packages/console/src/components/Alert/index.tsx +++ b/packages/console/src/components/Alert/index.tsx @@ -2,8 +2,8 @@ import { AdminConsoleKey } from '@logto/phrases'; import classNames from 'classnames'; import { ReactNode } from 'react'; +import Info from '@/assets/images/info.svg'; import LinkButton from '@/components/LinkButton'; -import Info from '@/icons/Info'; import Button from '../Button'; import * as styles from './index.module.scss'; @@ -38,7 +38,7 @@ const Alert = ({ )} {action && onClick && (
-
)} diff --git a/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss b/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss index fc8d799bd..1a9ba4768 100644 --- a/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss +++ b/packages/console/src/components/AppContent/components/Sidebar/components/Contact/index.module.scss @@ -26,7 +26,7 @@ .description { font: var(--font-body-medium); - color: var(--color-caption); + color: var(--color-text-secondary); } } diff --git a/packages/console/src/components/AppContent/components/Topbar/index.tsx b/packages/console/src/components/AppContent/components/Topbar/index.tsx index 6bb99e2fa..0478b5e7c 100644 --- a/packages/console/src/components/AppContent/components/Topbar/index.tsx +++ b/packages/console/src/components/AppContent/components/Topbar/index.tsx @@ -1,8 +1,8 @@ import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; +import Logo from '@/assets/images/logo.svg'; import Spacer from '@/components/Spacer'; -import Logo from '@/icons/Logo'; import GetStartedProgress from '@/pages/GetStarted/components/GetStartedProgress'; import UserInfo from '../UserInfo'; diff --git a/packages/console/src/components/AppContent/components/UserInfo/index.module.scss b/packages/console/src/components/AppContent/components/UserInfo/index.module.scss index e93ecff05..7e5525453 100644 --- a/packages/console/src/components/AppContent/components/UserInfo/index.module.scss +++ b/packages/console/src/components/AppContent/components/UserInfo/index.module.scss @@ -36,7 +36,7 @@ .role { font: var(--font-body-small); - color: var(--color-caption); + color: var(--color-text-secondary); } } } @@ -58,7 +58,7 @@ } .signOutIcon { - color: var(--color-icon); + color: var(--color-text-secondary); } .spinner { diff --git a/packages/console/src/components/AppContent/components/UserInfo/index.tsx b/packages/console/src/components/AppContent/components/UserInfo/index.tsx index 5586296a1..e37935890 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 SignOut from '@/assets/images/sign-out.svg'; 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..218b0a3d9 100644 --- a/packages/console/src/components/AppError/index.tsx +++ b/packages/console/src/components/AppError/index.tsx @@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next'; import ErrorDark from '@/assets/images/error-dark.svg'; import Error from '@/assets/images/error.svg'; +import KeyboardArrowDown from '@/assets/images/keyboard-arrow-down.svg'; +import KeyboardArrowUp from '@/assets/images/keyboard-arrow-up.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 +35,12 @@ const AppError = ({ title, errorCode, errorMessage, callStack, children }: Props {errorMessage} {callStack && ( { + setIsDetailsOpen(!isDetailsOpen); + })} onClick={() => { setIsDetailsOpen(!isDetailsOpen); }} diff --git a/packages/console/src/components/ApplicationName/index.tsx b/packages/console/src/components/ApplicationName/index.tsx index 650fac058..895760f7a 100644 --- a/packages/console/src/components/ApplicationName/index.tsx +++ b/packages/console/src/components/ApplicationName/index.tsx @@ -17,7 +17,7 @@ const ApplicationName = ({ applicationId, isLink = false }: Props) => { const { data } = useSWR(!isAdminConsole && `/api/applications/${applicationId}`); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const name = (isAdminConsole ? <>Admin Console ({t('system_app')}) : data?.name) || '-'; + const name = (isAdminConsole ? <>Admin Console ({t('system_app')}) : data?.name) ?? '-'; if (isLink && !isAdminConsole) { return ( diff --git a/packages/console/src/components/AuditLogTable/components/EventName/index.tsx b/packages/console/src/components/AuditLogTable/components/EventName/index.tsx index f3f10c82e..c7284ccba 100644 --- a/packages/console/src/components/AuditLogTable/components/EventName/index.tsx +++ b/packages/console/src/components/AuditLogTable/components/EventName/index.tsx @@ -1,9 +1,9 @@ import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import Failed from '@/assets/images/failed.svg'; +import Success from '@/assets/images/success.svg'; import { logEventTitle } from '@/consts/logs'; -import Failed from '@/icons/Failed'; -import Success from '@/icons/Success'; import * as styles from './index.module.scss'; diff --git a/packages/console/src/components/AuditLogTable/index.module.scss b/packages/console/src/components/AuditLogTable/index.module.scss index 66a32c543..cff0275e0 100644 --- a/packages/console/src/components/AuditLogTable/index.module.scss +++ b/packages/console/src/components/AuditLogTable/index.module.scss @@ -6,7 +6,7 @@ align-items: center; .title { - color: var(--color-caption); + color: var(--color-text-secondary); font: var(--font-body-medium); } diff --git a/packages/console/src/components/Button/index.module.scss b/packages/console/src/components/Button/index.module.scss index d61f06112..61820ff09 100644 --- a/packages/console/src/components/Button/index.module.scss +++ b/packages/console/src/components/Button/index.module.scss @@ -53,9 +53,8 @@ height: 30px; padding: 0 _.unit(3); - &.plain { + &.text { height: 24px; - padding: 0; } } @@ -63,9 +62,9 @@ height: 36px; padding: 0 _.unit(4); - &.plain { + &.text { + font: var(--font-subhead-1); height: 28px; - padding: 0; } } @@ -73,16 +72,17 @@ height: 44px; padding: 0 _.unit(6); - &.plain { - height: 28px; // same as medium - padding: 0; + &.text { + // same as medium + font: var(--font-subhead-1); + height: 28px; } } &.default { background: var(--color-layer-1); color: var(--color-text); - border-color: var(--color-outline); + border-color: var(--color-border); border-width: 1px; border-style: solid; @@ -196,11 +196,13 @@ } } - &.plain { + &.text { background: none; border-color: none; - font: var(--font-body-medium); + font: var(--font-label-large); color: var(--color-text-link); + padding: _.unit(0.5) _.unit(1); + border-radius: 4px; &:disabled { color: var(--color-disabled); @@ -211,7 +213,7 @@ } &:not(:disabled):hover { - text-decoration: underline; + background-color: var(--color-hover-variant); } } } diff --git a/packages/console/src/components/Button/index.tsx b/packages/console/src/components/Button/index.tsx index 9f0ee18fb..f5dd61d14 100644 --- a/packages/console/src/components/Button/index.tsx +++ b/packages/console/src/components/Button/index.tsx @@ -8,7 +8,7 @@ import { Ring as Spinner } from '@/components/Spinner'; import DangerousRaw from '../DangerousRaw'; import * as styles from './index.module.scss'; -export type ButtonType = 'primary' | 'danger' | 'outline' | 'plain' | 'default' | 'branding'; +export type ButtonType = 'primary' | 'danger' | 'outline' | 'text' | 'default' | 'branding'; type BaseProps = Omit, 'type' | 'size' | 'title'> & { htmlType?: 'button' | 'submit' | 'reset'; diff --git a/packages/console/src/components/CardTitle/index.module.scss b/packages/console/src/components/CardTitle/index.module.scss index be5798ae4..c29c3b8c5 100644 --- a/packages/console/src/components/CardTitle/index.module.scss +++ b/packages/console/src/components/CardTitle/index.module.scss @@ -10,7 +10,7 @@ .subtitle { margin-top: _.unit(1); - color: var(--color-caption); + color: var(--color-text-secondary); } &.large { diff --git a/packages/console/src/components/Checkbox/Icon.tsx b/packages/console/src/components/Checkbox/Icon.tsx index bac157b6f..1bd4028a5 100644 --- a/packages/console/src/components/Checkbox/Icon.tsx +++ b/packages/console/src/components/Checkbox/Icon.tsx @@ -1,13 +1,14 @@ import { AppearanceMode } from '@logto/schemas'; +import CheckBoxSelectedDark from '@/assets/images/check-box-selected-dark.svg'; +import CheckBoxSelectedDisabledDark from '@/assets/images/check-box-selected-disabled-dark.svg'; +import CheckBoxSelectedDisabled from '@/assets/images/check-box-selected-disabled.svg'; +import CheckBoxSelected from '@/assets/images/check-box-selected.svg'; +import CheckBoxUnselectedDark from '@/assets/images/check-box-unselected-dark.svg'; +import CheckBoxUnselectedDisabledDark from '@/assets/images/check-box-unselected-disabled-dark.svg'; +import CheckBoxUnselectedDisabled from '@/assets/images/check-box-unselected-disabled.svg'; +import CheckBoxUnselected from '@/assets/images/check-box-unselected.svg'; import { useTheme } from '@/hooks/use-theme'; -import CheckBoxSelected from '@/icons/CheckBoxSelected'; -import CheckBoxSelectedDisabled from '@/icons/CheckBoxSelectedDisabled'; -import CheckBoxSelectedDisabledDark from '@/icons/CheckBoxSelectedDisabledDark'; -import CheckBoxUnselected from '@/icons/CheckBoxUnselected'; -import CheckBoxUnselectedDark from '@/icons/CheckBoxUnselectedDark'; -import CheckBoxUnselectedDisabled from '@/icons/CheckBoxUnselectedDisabled'; -import CheckBoxUnselectedDisabledDark from '@/icons/CheckBoxUnselectedDisabledDark'; type Props = { className?: string; @@ -19,7 +20,7 @@ const Icon = ({ className }: Props) => { return ( - + {isLightMode ? : } {isLightMode ? : } {isLightMode ? : } {isLightMode ? : } diff --git a/packages/console/src/components/CopyToClipboard/index.module.scss b/packages/console/src/components/CopyToClipboard/index.module.scss index 542976b50..ae68d6b67 100644 --- a/packages/console/src/components/CopyToClipboard/index.module.scss +++ b/packages/console/src/components/CopyToClipboard/index.module.scss @@ -33,6 +33,11 @@ .copyIcon { margin-left: _.unit(3); + + svg { + width: 16px; + height: 16px; + } } } } diff --git a/packages/console/src/components/CopyToClipboard/index.tsx b/packages/console/src/components/CopyToClipboard/index.tsx index 9fb8b7d89..fd1b0aef8 100644 --- a/packages/console/src/components/CopyToClipboard/index.tsx +++ b/packages/console/src/components/CopyToClipboard/index.tsx @@ -2,9 +2,10 @@ import classNames from 'classnames'; import { MouseEventHandler, useEffect, useMemo, useRef, useState } from 'react'; import { TFuncKey, useTranslation } from 'react-i18next'; -import Copy from '@/icons/Copy'; -import Eye from '@/icons/Eye'; -import EyeClosed from '@/icons/EyeClosed'; +import Copy from '@/assets/images/copy.svg'; +import EyeClosed from '@/assets/images/eye-closed.svg'; +import Eye from '@/assets/images/eye.svg'; +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/FormField/index.module.scss b/packages/console/src/components/FormField/index.module.scss index fe1794d95..3fd003454 100644 --- a/packages/console/src/components/FormField/index.module.scss +++ b/packages/console/src/components/FormField/index.module.scss @@ -20,11 +20,11 @@ margin-left: _.unit(1); width: 16px; height: 16px; - color: var(--color-caption); + color: var(--color-text-secondary); } .required { font: var(--font-body-medium); - color: var(--color-caption); + color: var(--color-text-secondary); } } diff --git a/packages/console/src/components/FormField/index.tsx b/packages/console/src/components/FormField/index.tsx index f5f4c9965..19baebc6b 100644 --- a/packages/console/src/components/FormField/index.tsx +++ b/packages/console/src/components/FormField/index.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { ReactElement, ReactNode, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import Tip from '@/icons/Tip'; +import Tip from '@/assets/images/tip.svg'; import DangerousRaw from '../DangerousRaw'; import Spacer from '../Spacer'; diff --git a/packages/console/src/components/IconButton/index.module.scss b/packages/console/src/components/IconButton/index.module.scss index a9c6db380..f77264270 100644 --- a/packages/console/src/components/IconButton/index.module.scss +++ b/packages/console/src/components/IconButton/index.module.scss @@ -15,7 +15,7 @@ align-items: center; > svg { - color: var(--color-caption); + color: var(--color-text-secondary); } &:disabled { diff --git a/packages/console/src/components/IconButton/index.tsx b/packages/console/src/components/IconButton/index.tsx index 60dc4e313..14113cde2 100644 --- a/packages/console/src/components/IconButton/index.tsx +++ b/packages/console/src/components/IconButton/index.tsx @@ -1,18 +1,49 @@ +import { AdminConsoleKey } from '@logto/phrases'; +import { Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; -import { HTMLProps } from 'react'; +import { ForwardedRef, forwardRef, HTMLProps, useImperativeHandle, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import Tooltip from '../Tooltip'; import * as styles from './index.module.scss'; export type Props = Omit, 'size' | 'type'> & { size?: 'small' | 'medium' | 'large'; + tooltip?: AdminConsoleKey; }; -const IconButton = ({ size = 'medium', children, className, ...rest }: Props) => { +const IconButton = ( + { size = 'medium', children, className, tooltip, ...rest }: Props, + reference: ForwardedRef +) => { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const innerReference = useRef(null); + + useImperativeHandle, Nullable>( + reference, + () => innerReference.current + ); + return ( - + <> + + {tooltip && ( + + )} + ); }; -export default IconButton; +export default forwardRef(IconButton); diff --git a/packages/console/src/components/Index/index.tsx b/packages/console/src/components/Index/index.tsx index 2a33a2a7d..35554eaf4 100644 --- a/packages/console/src/components/Index/index.tsx +++ b/packages/console/src/components/Index/index.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import Tick from '@/icons/Tick'; +import Tick from '@/assets/images/tick.svg'; import * as styles from './index.module.scss'; diff --git a/packages/console/src/components/ItemPreview/index.module.scss b/packages/console/src/components/ItemPreview/index.module.scss index f1a89c1fe..e8f53fa71 100644 --- a/packages/console/src/components/ItemPreview/index.module.scss +++ b/packages/console/src/components/ItemPreview/index.module.scss @@ -21,7 +21,7 @@ .subtitle { font: var(--font-body-small); - color: var(--color-outline); + color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/packages/console/src/components/Markdown/index.module.scss b/packages/console/src/components/Markdown/index.module.scss index 41f7efdd8..86e0e268a 100644 --- a/packages/console/src/components/Markdown/index.module.scss +++ b/packages/console/src/components/Markdown/index.module.scss @@ -47,7 +47,7 @@ h2 { font: var(--font-title-medium); - color: var(--color-caption); + color: var(--color-text-secondary); margin: _.unit(6) 0 _.unit(3); } diff --git a/packages/console/src/components/ModalLayout/index.module.scss b/packages/console/src/components/ModalLayout/index.module.scss index 1e1abf9a5..76777ddbe 100644 --- a/packages/console/src/components/ModalLayout/index.module.scss +++ b/packages/console/src/components/ModalLayout/index.module.scss @@ -19,7 +19,7 @@ margin-bottom: _.unit(6); .closeIcon { - color: var(--color-icon); + color: var(--color-text-secondary); } } diff --git a/packages/console/src/components/ModalLayout/index.tsx b/packages/console/src/components/ModalLayout/index.tsx index cf6105507..39b431671 100644 --- a/packages/console/src/components/ModalLayout/index.tsx +++ b/packages/console/src/components/ModalLayout/index.tsx @@ -2,7 +2,7 @@ import { AdminConsoleKey } from '@logto/phrases'; import classNames from 'classnames'; import { ReactNode } from 'react'; -import Close from '@/icons/Close'; +import Close from '@/assets/images/close.svg'; import Card from '../Card'; import CardTitle from '../CardTitle'; diff --git a/packages/console/src/components/MultiTextInput/index.module.scss b/packages/console/src/components/MultiTextInput/index.module.scss index 8e7cf2fb0..d70a04d68 100644 --- a/packages/console/src/components/MultiTextInput/index.module.scss +++ b/packages/console/src/components/MultiTextInput/index.module.scss @@ -17,7 +17,7 @@ } .minusIcon { - color: var(--color-icon); + color: var(--color-text-secondary); } .addAnother { diff --git a/packages/console/src/components/MultiTextInput/index.tsx b/packages/console/src/components/MultiTextInput/index.tsx index bfe003174..e3b28690b 100644 --- a/packages/console/src/components/MultiTextInput/index.tsx +++ b/packages/console/src/components/MultiTextInput/index.tsx @@ -1,11 +1,10 @@ import { AdminConsoleKey } from '@logto/phrases'; -import classNames from 'classnames'; import { KeyboardEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import * as textButtonStyles from '@/components/TextButton/index.module.scss'; -import Minus from '@/icons/Minus'; +import Minus from '@/assets/images/minus.svg'; +import Button from '../Button'; import ConfirmModal from '../ConfirmModal'; import IconButton from '../IconButton'; import TextInput from '../TextInput'; @@ -85,9 +84,13 @@ const MultiTextInput = ({ title, value, onChange, onKeyPress, error, placeholder )}
    ))} -
    - {t('general.add_another')} -
    +
    ); diff --git a/packages/console/src/components/Select/index.module.scss b/packages/console/src/components/Select/index.module.scss index 89335599a..cd31d4f5f 100644 --- a/packages/console/src/components/Select/index.module.scss +++ b/packages/console/src/components/Select/index.module.scss @@ -42,7 +42,7 @@ .icon { display: flex; margin-left: _.unit(3); - color: var(--color-icon); + color: var(--color-text-secondary); } .clear { diff --git a/packages/console/src/components/Select/index.tsx b/packages/console/src/components/Select/index.tsx index c1dbe5ce0..2ca246f3b 100644 --- a/packages/console/src/components/Select/index.tsx +++ b/packages/console/src/components/Select/index.tsx @@ -1,8 +1,10 @@ import classNames from 'classnames'; import { ReactEventHandler, ReactNode, useRef, useState } from 'react'; -import { KeyboardArrowDown, KeyboardArrowUp } from '@/icons/Arrow'; -import Close from '@/icons/Close'; +import Close from '@/assets/images/close.svg'; +import KeyboardArrowDown from '@/assets/images/keyboard-arrow-down.svg'; +import KeyboardArrowUp from '@/assets/images/keyboard-arrow-up.svg'; +import { onKeyDownHandler } from '@/utilities/a11y'; import Dropdown, { DropdownItem } from '../Dropdown'; import IconButton from '../IconButton'; @@ -65,6 +67,12 @@ const Select = ({ 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.module.scss b/packages/console/src/components/TabNav/TabNavItem.module.scss index 78d4e907e..36f053a0c 100644 --- a/packages/console/src/components/TabNav/TabNavItem.module.scss +++ b/packages/console/src/components/TabNav/TabNavItem.module.scss @@ -9,7 +9,7 @@ a { display: inline-block; - color: var(--color-caption); + color: var(--color-text-secondary); text-decoration: none; cursor: pointer; padding-bottom: _.unit(1); 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/components/TextButton/index.module.scss b/packages/console/src/components/TextButton/index.module.scss deleted file mode 100644 index 35fa5c37d..000000000 --- a/packages/console/src/components/TextButton/index.module.scss +++ /dev/null @@ -1,23 +0,0 @@ -@use '@/scss/underscore' as _; - -.button { - display: inline-block; - font: var(--font-body-medium); - color: var(--color-text-link); - padding: _.unit(0.5) _.unit(1); - border-radius: _.unit(1); - text-decoration: none; - cursor: pointer; - - svg { - color: var(--color-primary); - } - - &:hover { - text-decoration: underline; - } - - &:focus { - outline: 2px solid var(--color-focused-variant); - } -} diff --git a/packages/console/src/components/TextInput/index.module.scss b/packages/console/src/components/TextInput/index.module.scss index 7750d3402..c9867d40f 100644 --- a/packages/console/src/components/TextInput/index.module.scss +++ b/packages/console/src/components/TextInput/index.module.scss @@ -36,7 +36,7 @@ padding: 0; &::placeholder { - color: var(--color-caption); + color: var(--color-placeholder); } // Overwrite webkit auto-fill style @@ -51,7 +51,7 @@ &::-webkit-calendar-picker-indicator { background-image: none; - background-color: var(--color-icon); + background-color: var(--color-text-secondary); mask-image: url('../../assets/images/calendar.png'); mask-size: 20px 20px; width: 16px; @@ -62,7 +62,7 @@ &.disabled { background: var(--color-inverse-on-surface); - color: var(--color-caption); + color: var(--color-text-secondary); border-color: var(--color-border); } diff --git a/packages/console/src/components/Textarea/index.module.scss b/packages/console/src/components/Textarea/index.module.scss new file mode 100644 index 000000000..330348fda --- /dev/null +++ b/packages/console/src/components/Textarea/index.module.scss @@ -0,0 +1,29 @@ +@use '@/scss/underscore' as _; + +.container { + border-radius: 6px; + border: 1px solid var(--color-border); + outline: 3px solid transparent; + padding: _.unit(2) _.unit(3); + + &:focus-within { + border-color: var(--color-primary); + outline-color: var(--color-focused-variant); + } + + textarea { + width: 100%; + height: 100%; + color: var(--color-text); + font: var(--font-body-medium); + background: transparent; + border: none; + outline: none; + resize: none; + padding: 0; + + &::placeholder { + color: var(--color-caption); + } + } +} diff --git a/packages/console/src/components/Textarea/index.tsx b/packages/console/src/components/Textarea/index.tsx new file mode 100644 index 000000000..0a6f40738 --- /dev/null +++ b/packages/console/src/components/Textarea/index.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import { ForwardedRef, forwardRef, HTMLProps } from 'react'; + +import * as styles from './index.module.scss'; + +type Props = HTMLProps & { + className?: string; +}; + +const Textarea = ({ className, ...rest }: Props, reference: ForwardedRef) => { + return ( +
    +