diff --git a/.github/workflows/alteration-compatibility-integration-test.yml b/.github/workflows/alteration-compatibility-integration-test.yml index f73c141d369..2b06b877c93 100644 --- a/.github/workflows/alteration-compatibility-integration-test.yml +++ b/.github/workflows/alteration-compatibility-integration-test.yml @@ -46,7 +46,7 @@ jobs: package: needs: check-alteration-changes - runs-on: buildjet-4vcpu-ubuntu-2204 + runs-on: ubuntu-latest if: ${{needs.check-alteration-changes.outputs.has-alteration-changes == 'true'}} env: INTEGRATION_TEST: true @@ -55,6 +55,7 @@ jobs: with: artifact-name: alteration-integration-test-${{ github.sha }} branch: ${{github.base_ref}} + pnpm-version: 9 run-logto: strategy: @@ -68,9 +69,10 @@ jobs: DB_URL: postgres://postgres:postgres@localhost:5432/postgres steps: - - uses: logto-io/actions-run-logto-integration-tests@v2 + - uses: logto-io/actions-run-logto-integration-tests@v3 with: branch: ${{github.base_ref}} - logto_artifact: alteration-integration-test-${{ github.sha }} - test_target: ${{ matrix.target }} - db_alteration_target: ${{github.head_ref}} + logto-artifact: alteration-integration-test-${{ github.sha }} + test-target: ${{ matrix.target }} + db-alteration-target: ${{github.head_ref}} + pnpm-version: 9 diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index 751c4f72689..0815cc3d372 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -19,6 +19,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Import GPG key uses: crazy-max/ghaction-import-gpg@v6 diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 0ee63394e9e..4e031d6450f 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -22,6 +22,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Commitlint # Credit to https://stackoverflow.com/a/67365254/12514940 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 6fcd3f321cd..0fd1557dbd8 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -13,7 +13,7 @@ concurrency: jobs: package: - runs-on: buildjet-4vcpu-ubuntu-2204 + runs-on: ubuntu-latest env: INTEGRATION_TEST: true @@ -21,6 +21,7 @@ jobs: - uses: logto-io/actions-package-logto-artifact@v2 with: artifact-name: integration-test-${{ github.sha }} + pnpm-version: 9 run-logto: strategy: @@ -34,7 +35,8 @@ jobs: DB_URL: postgres://postgres:postgres@localhost:5432/postgres steps: - - uses: logto-io/actions-run-logto-integration-tests@v2 + - uses: logto-io/actions-run-logto-integration-tests@v3 with: - logto_artifact: integration-test-${{ github.sha }} - test_target: ${{ matrix.target }} + logto-artifact: integration-test-${{ github.sha }} + test-target: ${{ matrix.target }} + pnpm-version: 9 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a0af63a9655..2bd1a72767d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,6 +20,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Build run: pnpm ci:build @@ -32,6 +34,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Prepack run: pnpm prepack @@ -43,13 +47,15 @@ jobs: run: pnpm ci:stylelint main-test: - runs-on: buildjet-4vcpu-ubuntu-2204 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Build for test run: pnpm -r build:test @@ -62,12 +68,14 @@ jobs: with: flags: core directory: ./packages/core + token: ${{ secrets.CODECOV_TOKEN }} - name: Codecov ui uses: codecov/codecov-action@v4 with: flags: ui directory: ./packages/ui + token: ${{ secrets.CODECOV_TOKEN }} main-dockerize: runs-on: ubuntu-latest @@ -114,6 +122,7 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 with: + pnpm-version: 9 run-install: false # ** Prepack packages ** @@ -123,7 +132,11 @@ jobs: - name: Prepack alteration working-directory: ./alteration - run: pnpm i && pnpm prepack + run: | + # Remove corepack commands once a new Logto release is out + corepack enable pnpm + corepack use pnpm@8 + pnpm i && pnpm prepack # ** End ** - name: Setup Postgres diff --git a/.github/workflows/master-codecov-report.yml b/.github/workflows/master-codecov-report.yml index fedeedcd65a..9f1efb01c04 100644 --- a/.github/workflows/master-codecov-report.yml +++ b/.github/workflows/master-codecov-report.yml @@ -15,6 +15,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Build for test run: pnpm -r build:test @@ -27,9 +29,11 @@ jobs: with: flags: core directory: ./packages/core + token: ${{ secrets.CODECOV_TOKEN }} - name: Codecov UI uses: codecov/codecov-action@v4 with: flags: experience directory: ./packages/experience + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pen-tests.yml b/.github/workflows/pen-tests.yml index d20cb20f6ed..eb70b647458 100644 --- a/.github/workflows/pen-tests.yml +++ b/.github/workflows/pen-tests.yml @@ -32,7 +32,7 @@ jobs: run: sleep 30s - name: ZAP Scan - uses: zaproxy/action-full-scan@v0.9.0 + uses: zaproxy/action-full-scan@v0.10.0 with: target: http://localhost:3001 cmd_options: "-a" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4f6eb2b88b..c942800e52f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ concurrency: jobs: dockerize: if: startsWith(github.ref, 'refs/tags/') - environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }} + environment: release runs-on: ubuntu-latest permissions: contents: read @@ -63,6 +63,56 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Build and push the edge image on every master push + # Use official docker workflows since we only need to build amd64 images. + dockerize-edge: + if: ${{ !startsWith(github.ref, 'refs/tags/')}} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/logto-io/logto + svhd/logto + tags: | + type=edge + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: | + ghcr.io + username: silverhand-bot + password: ${{ secrets.BOT_PAT }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push docker image + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64 + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # Publish packages and create git tags if needed publish-and-tag: runs-on: ubuntu-latest @@ -80,6 +130,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Import GPG key uses: crazy-max/ghaction-import-gpg@v6 @@ -110,6 +162,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Build run: pnpm -r build diff --git a/.github/workflows/upload-annotations.yml b/.github/workflows/upload-annotations.yml index a7d48c5ce80..835c8fd07ba 100644 --- a/.github/workflows/upload-annotations.yml +++ b/.github/workflows/upload-annotations.yml @@ -23,6 +23,8 @@ jobs: - name: Setup Node and pnpm uses: silverhand-io/actions-node-pnpm-run-steps@v4 + with: + pnpm-version: 9 - name: Prepack run: pnpm prepack @@ -31,9 +33,9 @@ jobs: run: pnpm -r --parallel lint:report && node .scripts/merge-eslint-reports.js - name: Annotate Code Linting Results - uses: ataylorme/eslint-annotate-action@2.2.0 + uses: ataylorme/eslint-annotate-action@3.0.0 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload ESLint report uses: actions/upload-artifact@v4 diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs deleted file mode 100644 index 8add22ecf0e..00000000000 --- a/.pnpmfile.cjs +++ /dev/null @@ -1,55 +0,0 @@ -// See https://pnpm.io/pnpmfile -const fs = require('node:fs/promises'); -const path = require('node:path'); - -// Types are inspected and edited from https://github.com/pnpm/pnpm/blob/ef6c22e129dc3d76998cee33647b70a66d1f36bf/hooks/pnpmfile/src/requireHooks.ts -/** - * @typedef {Object} HookContext - * @property {(message: string) => void} log - */ - -/** - * @typedef {Object} Hooks - * @property {((pkg: unknown, context: HookContext) => unknown)=} readPackage - */ - -const isObject = (value) => value !== null && typeof value === 'object'; - -/** @type Hooks */ -const hooks = { readPackage: async (pkg) => { - // Skip if not a connector package - if (!( - isObject(pkg) && - 'name' in pkg && - String(pkg.name) !== '@logto/connector-kit' && - String(pkg.name).startsWith('@logto/connector-') - )) { - return pkg; - } - - // Apply connector's `package.json` to the template - const result = JSON.parse( - // Use `__dirname` since the `pnpm i` command may be executed in nested workspace directories - await fs.readFile(path.join(__dirname, 'packages/connectors/templates/package.json'), 'utf8') - ); - for (const [key, value] of Object.entries(pkg)) { - if (key === '$schema') { - continue; - } - - // Shallow merge - if (Array.isArray(result[key])) { - result[key] = [...result[key], ...value]; - } else if (typeof value === 'object' && value !== null) { - result[key] = { ...result[key], ...value }; - } else { - result[key] = value; - } - } - - return result; -} }; - -module.exports = { - hooks, -}; diff --git a/.vscode/settings.json b/.vscode/settings.json index d69e1c7202c..b83dc8dfa43 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,20 +39,21 @@ "CIAM", "codecov", "hasura", + "huggingface", "Logto", + "mailgun", "oidc", "passcode", "passcodes", "Passwordless", "pnpm", + "sendgrid", "silverhand", "slonik", "stylelint", "timestamptz", "topbar", - "withtyped", - "sendgrid", - "mailgun", "upsell", + "withtyped" ] } diff --git a/.zap/rules.conf b/.zap/rules.conf index 395184be14d..b19a3b81c41 100644 --- a/.zap/rules.conf +++ b/.zap/rules.conf @@ -12,3 +12,6 @@ # The applicationInsights endpoint will be removed 10055 IGNORE (CSP - Wildcard Directive) + +# Experience app is rendered under the root path. No hidden files are exposed. A 404 experience page will be returned. +40035 IGNORE (Hidden File Found - Active/release) diff --git a/Dockerfile b/Dockerfile index 20f9b6ee933..c0176ea433d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ ENV PUPPETEER_SKIP_DOWNLOAD=true ENV PORT=3301 ENV ADMIN_PORT=3302 ### Install toolchain ### -RUN npm add --location=global pnpm@^8.0.0 +RUN npm add --location=global pnpm@^9.0.0 # https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#node-gyp-alpine RUN apk add --no-cache python3 make g++ rsync diff --git a/package.json b/package.json index a003ff86acc..28ff7ea7f52 100644 --- a/package.json +++ b/package.json @@ -36,14 +36,17 @@ }, "engines": { "node": "^20.9.0", - "pnpm": "^8.10.0" + "pnpm": "^9.0.0" }, "pnpm": { + "overrides": { + "formidable@<3.2.4": "^3.2.4" + }, "peerDependencyRules": { "allowedVersions": { "react": "^18.0.0", "jest": "^29.1.2", - "stylelint": "^15.0.0" + "stylelint": "^16.0.0" } } }, diff --git a/packages/app-insights/CHANGELOG.md b/packages/app-insights/CHANGELOG.md index 8fb362efabb..cb2d34140a2 100644 --- a/packages/app-insights/CHANGELOG.md +++ b/packages/app-insights/CHANGELOG.md @@ -1,5 +1,15 @@ # @logto/app-insights +## 2.0.0 + +### Major Changes + +- c1c746bca: remove application insights for react + +### Patch Changes + +- a9ccfc738: allow additional telemetry for `trackException()` + ## 1.4.0 ### Minor Changes diff --git a/packages/app-insights/package.json b/packages/app-insights/package.json index dc5229948e2..d229f4ae450 100644 --- a/packages/app-insights/package.json +++ b/packages/app-insights/package.json @@ -1,6 +1,6 @@ { "name": "@logto/app-insights", - "version": "1.4.0", + "version": "2.0.0", "main": "lib/index.js", "author": "Silverhand Inc. ", "license": "MPL-2.0", @@ -12,10 +12,6 @@ "./*": { "import": "./lib/*.js", "types": "./lib/*.d.ts" - }, - "./react": { - "import": "./lib/react/index.js", - "types": "./lib/react/index.d.ts" } }, "publishConfig": { @@ -33,18 +29,13 @@ "prepack": "pnpm build" }, "devDependencies": { - "@silverhand/eslint-config": "5.0.0", - "@silverhand/eslint-config-react": "5.0.0", - "@silverhand/ts-config": "5.0.0", - "@silverhand/ts-config-react": "5.0.0", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", "@types/node": "^20.9.5", - "@types/react": "^18.0.31", "@vitest/coverage-v8": "^1.4.0", - "eslint": "^8.44.0", - "history": "^5.3.0", + "eslint": "^8.56.0", "lint-staged": "^15.0.0", "prettier": "^3.0.0", - "react": "^18.0.0", "typescript": "^5.3.3", "vitest": "^1.4.0" }, @@ -52,28 +43,17 @@ "node": "^20.9.0" }, "eslintConfig": { - "extends": "@silverhand/react" + "extends": "@silverhand" }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { - "@microsoft/applicationinsights-clickanalytics-js": "^3.0.2", - "@microsoft/applicationinsights-react-js": "^17.0.0", - "@microsoft/applicationinsights-web": "^3.0.2", "@silverhand/essentials": "^2.9.0", - "applicationinsights": "^2.7.0" + "applicationinsights": "^2.9.5" }, "peerDependencies": { - "history": "^5.3.0", - "react": "^18.0.0", "tslib": "^2.4.1" }, "peerDependenciesMeta": { - "history": { - "optional": true - }, - "react": { - "optional": true - }, "tslib": { "optional": true } diff --git a/packages/app-insights/src/node.ts b/packages/app-insights/src/node.ts index 5ea63fb74bc..d9511880974 100644 --- a/packages/app-insights/src/node.ts +++ b/packages/app-insights/src/node.ts @@ -1,8 +1,11 @@ import { trySafe } from '@silverhand/essentials'; import type { TelemetryClient } from 'applicationinsights'; +import { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js'; import { normalizeError } from './normalize-error.js'; +export { type ExceptionTelemetry } from 'applicationinsights/out/Declarations/Contracts/index.js'; + class AppInsights { client?: TelemetryClient; @@ -29,9 +32,14 @@ class AppInsights { return true; } - /** The function is async to avoid blocking the main script and force the use of `await` or `void`. */ - async trackException(error: unknown) { - this.client?.trackException({ exception: normalizeError(error) }); + /** + * The function is async to avoid blocking the main script and force the use of `await` or `void`. + * + * @param error The error to track. It will be normalized for better telemetry. + * @param telemetry Additional telemetry to include in the exception. + */ + async trackException(error: unknown, telemetry?: Partial) { + this.client?.trackException({ exception: normalizeError(error), ...telemetry }); } } diff --git a/packages/app-insights/src/normalize-error.ts b/packages/app-insights/src/normalize-error.ts index 36799b9ed5a..fd1d5821280 100644 --- a/packages/app-insights/src/normalize-error.ts +++ b/packages/app-insights/src/normalize-error.ts @@ -36,11 +36,7 @@ function getCircularReplacer() { return function (this: unknown, key: string, value: unknown) { // Ignore `stack` property since ApplicationInsights will show it - if ( - isObject(this) && - Object.prototype.hasOwnProperty.call(this, transformedKey) && - key === 'stack' - ) { + if (isObject(this) && Object.hasOwn(this, transformedKey) && key === 'stack') { return; } diff --git a/packages/app-insights/src/react/AppInsightsBoundary.tsx b/packages/app-insights/src/react/AppInsightsBoundary.tsx deleted file mode 100644 index 34444a79a38..00000000000 --- a/packages/app-insights/src/react/AppInsightsBoundary.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { type ReactNode, useContext, useEffect } from 'react'; - -import { AppInsightsContext, AppInsightsProvider } from './context.js'; -import { getPrimaryDomain } from './utils.js'; - -type AppInsightsProps = { - cloudRole: string; -}; - -const AppInsights = ({ cloudRole }: AppInsightsProps) => { - const { needsSetup, setup } = useContext(AppInsightsContext); - - useEffect(() => { - const run = async () => { - await setup(cloudRole, { cookieDomain: getPrimaryDomain() }); - }; - - if (needsSetup) { - void run(); - } - }, [cloudRole, needsSetup, setup]); - - return null; -}; - -type Props = AppInsightsProps & { - children: ReactNode; -}; - -/** - * **CAUTION:** Make sure to put this component inside `` or any other - * context providers that are render-sensitive, since we are lazy loading ApplicationInsights SDKs - * for better user experience. - * - * This component will trigger a render after the ApplicationInsights SDK is loaded which may - * cause issues for some context providers. For example, `useHandleSignInCallback` will be - * called twice if you use this component to wrap a ``. - */ -const AppInsightsBoundary = ({ children, ...rest }: Props) => { - return ( - - - {children} - - ); -}; - -export default AppInsightsBoundary; diff --git a/packages/app-insights/src/react/AppInsightsReact.ts b/packages/app-insights/src/react/AppInsightsReact.ts deleted file mode 100644 index 69a06851130..00000000000 --- a/packages/app-insights/src/react/AppInsightsReact.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { type ClickAnalyticsPlugin } from '@microsoft/applicationinsights-clickanalytics-js'; -import type { ReactPlugin, withAITracking } from '@microsoft/applicationinsights-react-js'; -import type { ApplicationInsights, ITelemetryPlugin } from '@microsoft/applicationinsights-web'; -import { conditional, conditionalArray, type Optional } from '@silverhand/essentials'; -import { type ComponentType } from 'react'; - -export type SetupConfig = { - connectionString?: string; - /** - * The config object for the ClickAnalytics plugin. If this is provided, the plugin will be - * automatically loaded when calling `.setup()`. - * - * Wait for {@link https://github.com/microsoft/ApplicationInsights-JS/issues/2106 | microsoft/ApplicationInsights-JS#2106} - * to be resolved to use a stronger type. - * - * @see {@link https://github.com/microsoft/ApplicationInsights-JS/tree/master/extensions/applicationinsights-clickanalytics-js#configuration | ClickAnalytics configuration} - */ - clickPlugin?: Record; - cookieDomain?: string; -}; - -export class AppInsightsReact { - /** - * URL search parameters that start with `utm_`. It is an empty object until you call `.setup()`, - * which will read the URL search string and store parameters in this property. - */ - utmParameters: Record = {}; - - protected reactPlugin?: ReactPlugin; - protected clickAnalyticsPlugin?: ClickAnalyticsPlugin; - protected withAITracking?: typeof withAITracking; - protected appInsights?: ApplicationInsights; - - get instance(): Optional { - return this.appInsights; - } - - get trackPageView(): Optional { - return this.appInsights?.trackPageView.bind(this.appInsights); - } - - async setup(cloudRole: string, config?: string | SetupConfig): Promise { - const connectionStringFromConfig = - typeof config === 'string' ? config : config?.connectionString; - // The string needs to be normalized since it may contain '"' - const connectionString = ( - connectionStringFromConfig ?? process.env.APPLICATIONINSIGHTS_CONNECTION_STRING - )?.replace(/^"?(.*)"?$/g, '$1'); - - if (!connectionString) { - return false; - } - - if (this.appInsights?.config.connectionString === connectionString) { - return true; - } - - try { - // Lazy load ApplicationInsights modules - const { ReactPlugin, withAITracking } = await import( - '@microsoft/applicationinsights-react-js' - ); - const { ApplicationInsights } = await import('@microsoft/applicationinsights-web'); - - // Conditionally load ClickAnalytics plugin - const configObject = conditional(typeof config === 'object' && config) ?? {}; - const { cookieDomain, clickPlugin } = configObject; - const initClickAnalyticsPlugin = async () => { - const { ClickAnalyticsPlugin } = await import( - '@microsoft/applicationinsights-clickanalytics-js' - ); - return new ClickAnalyticsPlugin(); - }; - - // Assign React props - // https://github.com/microsoft/applicationinsights-react-js#readme - this.withAITracking = withAITracking; - this.reactPlugin = new ReactPlugin(); - - // Assign ClickAnalytics prop - this.clickAnalyticsPlugin = conditional(clickPlugin && (await initClickAnalyticsPlugin())); - - // Init ApplicationInsights instance - this.appInsights = new ApplicationInsights({ - config: { - cookieDomain, - connectionString, - enableAutoRouteTracking: false, - extensions: conditionalArray( - this.reactPlugin, - this.clickAnalyticsPlugin - ), - extensionConfig: conditional( - this.clickAnalyticsPlugin && { - [this.clickAnalyticsPlugin.identifier]: clickPlugin, - } - ), - }, - }); - - // Extract UTM parameters - const searchParams = [...new URLSearchParams(window.location.search).entries()]; - this.utmParameters = Object.fromEntries( - searchParams.filter(([key]) => key.startsWith('utm_')) - ); - - this.appInsights.addTelemetryInitializer((item) => { - // @see https://github.com/microsoft/ApplicationInsights-JS#example-setting-cloud-role-name - // @see https://github.com/microsoft/ApplicationInsights-node.js/blob/a573e40fc66981c6a3106bdc5b783d1d94f64231/Schema/PublicSchema/ContextTagKeys.bond#L83 - /* eslint-disable @silverhand/fp/no-mutation */ - item.tags = [...(item.tags ?? []), { 'ai.cloud.role': cloudRole }]; - /* eslint-enable @silverhand/fp/no-mutation */ - }); - - this.appInsights.loadAppInsights(); - } catch (error: unknown) { - console.error('Unable to init ApplicationInsights:'); - console.error(error); - - return false; - } - - return true; - } - - withAppInsights

(Component: ComponentType

): ComponentType

{ - if (!this.reactPlugin || !this.withAITracking) { - return Component; - } - - return this.withAITracking(this.reactPlugin, Component, undefined, 'appInsightsWrapper'); - } -} - -export const appInsightsReact = new AppInsightsReact(); - -export const withAppInsights = appInsightsReact.withAppInsights.bind(appInsightsReact); diff --git a/packages/app-insights/src/react/TrackOnce.tsx b/packages/app-insights/src/react/TrackOnce.tsx deleted file mode 100644 index d2975dd2cd6..00000000000 --- a/packages/app-insights/src/react/TrackOnce.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { type ICustomProperties } from '@microsoft/applicationinsights-web'; -import { yes } from '@silverhand/essentials'; -import { useContext, useEffect } from 'react'; - -import { getEventName, type Component, type EventType } from '../custom-event.js'; - -import { AppInsightsContext } from './context.js'; - -type Props = { - component: C; - event: EventType; - customProperties?: ICustomProperties; -}; - -const storageKeyPrefix = 'logto:insights:'; - -/** Track an event after AppInsights SDK is setup, but only once during the current session. */ -const TrackOnce = ({ component, event, customProperties }: Props) => { - const { isSetupFinished, appInsights } = useContext(AppInsightsContext); - - useEffect(() => { - const eventName = getEventName(component, event); - const storageKey = `${storageKeyPrefix}${eventName}`; - const tracked = yes(sessionStorage.getItem(storageKey)); - - if (isSetupFinished && !tracked) { - appInsights.instance?.trackEvent( - { - name: getEventName(component, event), - }, - { ...appInsights.utmParameters, ...customProperties } - ); - sessionStorage.setItem(storageKey, '1'); - } - }, [ - appInsights.instance, - appInsights.utmParameters, - component, - customProperties, - event, - isSetupFinished, - ]); - - return null; -}; - -export default TrackOnce; diff --git a/packages/app-insights/src/react/context.tsx b/packages/app-insights/src/react/context.tsx deleted file mode 100644 index 26489bd8f7a..00000000000 --- a/packages/app-insights/src/react/context.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { type ReactNode, createContext, useMemo, useState, useCallback } from 'react'; - -import { type AppInsightsReact, appInsightsReact as appInsights } from './AppInsightsReact'; - -const notImplemented = () => { - throw new Error('Not implemented'); -}; - -type Context = { - needsSetup: boolean; - isSetupFinished: boolean; - setup: (...args: Parameters) => Promise; - appInsights: AppInsightsReact; -}; - -export const AppInsightsContext = createContext({ - needsSetup: true, - isSetupFinished: false, - setup: notImplemented, - appInsights, -}); - -type Properties = { - children: ReactNode; -}; - -export type SetupStatus = 'none' | 'loading' | 'initialized' | 'failed'; - -export const AppInsightsProvider = ({ children }: Properties) => { - const [setupStatus, setSetupStatus] = useState('none'); - const setup = useCallback( - async (...args: Parameters) => { - if (setupStatus !== 'none') { - return; - } - - setSetupStatus('loading'); - const result = await appInsights.setup(...args); - - if (result) { - console.debug('Initialized ApplicationInsights'); - setSetupStatus('initialized'); - } else { - setSetupStatus('failed'); - } - }, - [setupStatus] - ); - - const context = useMemo( - () => ({ - needsSetup: setupStatus === 'none', - isSetupFinished: setupStatus === 'initialized' || setupStatus === 'failed', - setup, - appInsights, - }), - [setup, setupStatus] - ); - - return {children}; -}; diff --git a/packages/app-insights/src/react/index.ts b/packages/app-insights/src/react/index.ts deleted file mode 100644 index 638c4f0dfa9..00000000000 --- a/packages/app-insights/src/react/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { AppInsightsReact, type SetupConfig, withAppInsights } from './AppInsightsReact.js'; -export * from './context.js'; -export * from './utils.js'; -export { default as AppInsightsBoundary } from './AppInsightsBoundary.js'; -export { default as TrackOnce } from './TrackOnce.js'; diff --git a/packages/app-insights/src/react/utils.ts b/packages/app-insights/src/react/utils.ts deleted file mode 100644 index 636664f4bf7..00000000000 --- a/packages/app-insights/src/react/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * **CAUTION:** This function takes the last two parts of the hostname which may cause issues for - * some second-level domains, e.g. `.co.uk`. - */ -export const getPrimaryDomain = () => window.location.hostname.split('.').slice(-2).join('.'); diff --git a/packages/app-insights/tsconfig.json b/packages/app-insights/tsconfig.json index 2e988e53b2f..72888320fc5 100644 --- a/packages/app-insights/tsconfig.json +++ b/packages/app-insights/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@silverhand/ts-config-react/tsconfig.base", + "extends": "@silverhand/ts-config/tsconfig.base", "compilerOptions": { "outDir": "lib", "types": ["node"], diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 44a57e0f0c3..e84dc4e98b1 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## 1.16.0 + +### Patch Changes + +- Updated dependencies [21bb35b12] +- Updated dependencies [5b03030de] +- Updated dependencies [e8c41b164] +- Updated dependencies [21bb35b12] +- Updated dependencies [3486b12e8] + - @logto/schemas@1.16.0 + - @logto/phrases@1.10.1 + - @logto/shared@3.1.1 + ## 1.15.0 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index be66801bc57..56a6ce293f5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@logto/cli", - "version": "1.15.0", + "version": "1.16.0", "description": "Logto CLI.", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", @@ -45,10 +45,10 @@ "@logto/connector-kit": "workspace:^3.0.0", "@logto/core-kit": "workspace:^2.4.0", "@logto/language-kit": "workspace:^1.1.0", - "@logto/phrases": "workspace:^1.10.0", + "@logto/phrases": "workspace:^1.10.1", "@logto/phrases-experience": "workspace:^1.6.1", - "@logto/schemas": "workspace:1.15.0", - "@logto/shared": "workspace:^3.1.0", + "@logto/schemas": "workspace:1.16.0", + "@logto/shared": "workspace:^3.1.1", "@silverhand/essentials": "^2.9.0", "@silverhand/slonik": "31.0.0-beta.2", "chalk": "^5.0.0", @@ -65,23 +65,23 @@ "pg-protocol": "^1.6.0", "roarr": "^7.11.0", "semver": "^7.3.8", - "tar": "^6.1.11", + "tar": "^7.0.0", "typescript": "^5.3.3", "yargs": "^17.6.0", "zod": "^3.22.4" }, "devDependencies": { - "@silverhand/eslint-config": "5.0.0", - "@silverhand/ts-config": "5.0.0", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", "@types/inquirer": "^9.0.0", "@types/node": "^20.9.5", "@types/semver": "^7.3.12", "@types/sinon": "^17.0.0", - "@types/tar": "^6.1.2", + "@types/tar": "^6.1.12", "@types/yargs": "^17.0.13", "@vitest/coverage-v8": "^1.4.0", - "@withtyped/server": "^0.13.3", - "eslint": "^8.44.0", + "@withtyped/server": "^0.13.6", + "eslint": "^8.56.0", "lint-staged": "^15.0.0", "prettier": "^3.0.0", "sinon": "^17.0.0", diff --git a/packages/cli/src/commands/connector/utils.ts b/packages/cli/src/commands/connector/utils.ts index 858dfd4d69f..06c36cbc76b 100644 --- a/packages/cli/src/commands/connector/utils.ts +++ b/packages/cli/src/commands/connector/utils.ts @@ -9,7 +9,7 @@ import chalk from 'chalk'; import { got } from 'got'; import pLimit from 'p-limit'; import pRetry from 'p-retry'; -import tar from 'tar'; +import { extract } from 'tar'; import { z } from 'zod'; import { connectorDirectory, coreDirectory } from '../../constants.js'; @@ -102,7 +102,7 @@ export const addConnectorsToPath = async (cwd: string, packageNames: string[]) = await fs.rm(packageDirectory, { force: true, recursive: true }); await fs.mkdir(packageDirectory, { recursive: true }); - await tar.extract({ cwd: packageDirectory, file: tarPath, strip: 1 }); + await extract({ cwd: packageDirectory, file: tarPath, strip: 1 }); await fs.unlink(tarPath); consoleLog.succeed(`Added ${chalk.green(name)} v${version}`); diff --git a/packages/cli/src/commands/install/utils.ts b/packages/cli/src/commands/install/utils.ts index 0ddd9b7692f..f39d90ec9a8 100644 --- a/packages/cli/src/commands/install/utils.ts +++ b/packages/cli/src/commands/install/utils.ts @@ -9,7 +9,7 @@ import chalk from 'chalk'; import { got, RequestError } from 'got'; import inquirer from 'inquirer'; import * as semver from 'semver'; -import tar from 'tar'; +import { extract } from 'tar'; import { defaultPath } from '../../constants.js'; import { createPoolAndDatabaseIfNeeded } from '../../database.js'; @@ -140,7 +140,7 @@ export const decompress = async (toPath: string, tarPath: string) => { const run = async () => { try { await fs.mkdir(toPath); - await tar.extract({ file: tarPath, cwd: toPath, strip: 1 }); + await extract({ file: tarPath, cwd: toPath, strip: 1 }); } catch (error: unknown) { consoleLog.fatal(error); } diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 825acb600b8..66ad32d785c 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -68,7 +68,7 @@ export const downloadFile = async (url: string, destination: string) => { file.on('error', (error) => { spinner.fail(); - reject(error.message); + reject(new Error(error.message)); }); file.on('finish', () => { diff --git a/packages/connectors/connector-alipay-native/package.json b/packages/connectors/connector-alipay-native/package.json index b490e627503..84227e3f274 100644 --- a/packages/connectors/connector-alipay-native/package.json +++ b/packages/connectors/connector-alipay-native/package.json @@ -5,11 +5,33 @@ "author": "Silverhand Inc. ", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", "dayjs": "^1.10.5", - "iconv-lite": "^0.6.3" + "got": "^14.0.0", + "iconv-lite": "^0.6.3", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "devDependencies": { - "@shopify/jest-koa-mocks": "^5.0.0" + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@shopify/jest-koa-mocks": "^5.0.0", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" }, "main": "./lib/index.js", "module": "./lib/index.js", diff --git a/packages/connectors/connector-alipay-web/package.json b/packages/connectors/connector-alipay-web/package.json index 2dcc0397c42..b65a156b7d0 100644 --- a/packages/connectors/connector-alipay-web/package.json +++ b/packages/connectors/connector-alipay-web/package.json @@ -4,11 +4,33 @@ "description": "Alipay implementation.", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", "dayjs": "^1.10.5", - "iconv-lite": "^0.6.3" + "got": "^14.0.0", + "iconv-lite": "^0.6.3", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "devDependencies": { - "@shopify/jest-koa-mocks": "^5.0.0" + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@shopify/jest-koa-mocks": "^5.0.0", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" }, "main": "./lib/index.js", "module": "./lib/index.js", diff --git a/packages/connectors/connector-aliyun-dm/package.json b/packages/connectors/connector-aliyun-dm/package.json index 1871c95491c..9f3ee0b478e 100644 --- a/packages/connectors/connector-aliyun-dm/package.json +++ b/packages/connectors/connector-aliyun-dm/package.json @@ -3,7 +3,11 @@ "version": "1.1.1", "description": "Aliyun DM connector implementation.", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -45,5 +49,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-aliyun-sms/package.json b/packages/connectors/connector-aliyun-sms/package.json index 5bd96d213fa..f8bd264496d 100644 --- a/packages/connectors/connector-aliyun-sms/package.json +++ b/packages/connectors/connector-aliyun-sms/package.json @@ -3,7 +3,11 @@ "version": "1.1.1", "description": "Aliyun SMS connector implementation.", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -45,5 +49,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-apple/package.json b/packages/connectors/connector-apple/package.json index cb9ae15e3a0..64da97b4b6f 100644 --- a/packages/connectors/connector-apple/package.json +++ b/packages/connectors/connector-apple/package.json @@ -5,7 +5,11 @@ "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", "@logto/shared": "workspace:^3.1.0", - "jose": "^5.0.0" + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "jose": "^5.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -47,5 +51,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-aws-ses/package.json b/packages/connectors/connector-aws-ses/package.json index bfc993a75d5..397fd55d50e 100644 --- a/packages/connectors/connector-aws-ses/package.json +++ b/packages/connectors/connector-aws-ses/package.json @@ -4,9 +4,13 @@ "description": "Logto Connector for Amazon SES", "author": "Jeff ", "dependencies": { + "@aws-sdk/client-sesv2": "^3.556.0", + "@aws-sdk/types": "^3.535.0", "@logto/connector-kit": "workspace:^3.0.0", - "@aws-sdk/client-sesv2": "^3.224.0", - "@aws-sdk/types": "^3.226.0" + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -48,5 +52,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-azuread/package.json b/packages/connectors/connector-azuread/package.json index 236885e7cc9..ec941edc84a 100644 --- a/packages/connectors/connector-azuread/package.json +++ b/packages/connectors/connector-azuread/package.json @@ -5,7 +5,11 @@ "author": "Mobilist Inc. ", "dependencies": { "@azure/msal-node": "^2.0.0", - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -49,7 +53,23 @@ "access": "public" }, "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-discord/package.json b/packages/connectors/connector-discord/package.json index c1c148df7f5..0d585c35ed0 100644 --- a/packages/connectors/connector-discord/package.json +++ b/packages/connectors/connector-discord/package.json @@ -4,7 +4,11 @@ "description": "Discord connector implementation.", "author": "ZR3SYSTEMS. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-facebook/package.json b/packages/connectors/connector-facebook/package.json index ec63896d6a5..a9adc562727 100644 --- a/packages/connectors/connector-facebook/package.json +++ b/packages/connectors/connector-facebook/package.json @@ -4,7 +4,11 @@ "description": "Facebook web connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-feishu-web/package.json b/packages/connectors/connector-feishu-web/package.json index 7fc99edaaef..50bc45c13b0 100644 --- a/packages/connectors/connector-feishu-web/package.json +++ b/packages/connectors/connector-feishu-web/package.json @@ -4,7 +4,11 @@ "description": "Feishu web connector.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-feishu-web/src/index.ts b/packages/connectors/connector-feishu-web/src/index.ts index a2b25b2f929..56ed8cccc99 100644 --- a/packages/connectors/connector-feishu-web/src/index.ts +++ b/packages/connectors/connector-feishu-web/src/index.ts @@ -153,7 +153,7 @@ export function getUserInfo(getConfig: GetConnectorConfig): GetUserInfo { avatar, email: conditional(email), userId: conditional(user_id), - phone: conditional(mobile), + phone: conditional(mobile?.replace('+', '')), rawData: jsonGuard.parse(response.body), }; } catch (error: unknown) { diff --git a/packages/connectors/connector-github/CHANGELOG.md b/packages/connectors/connector-github/CHANGELOG.md index 73b8a2eb0ee..1866264bb7c 100644 --- a/packages/connectors/connector-github/CHANGELOG.md +++ b/packages/connectors/connector-github/CHANGELOG.md @@ -1,5 +1,14 @@ # @logto/connector-github +## 1.4.0 + +### Minor Changes + +- 0227822b2: fetch GitHub account's private email address list and pick the verified primary email as a fallback + + - Add `user:email` as part of default scope to fetch GitHub account's private email address list + - Pick the verified primary email among private email address list as a fallback if the user does not set a public email for GitHub account + ## 1.3.0 ### Minor Changes diff --git a/packages/connectors/connector-github/package.json b/packages/connectors/connector-github/package.json index 30bd1beaee2..233070e6f2f 100644 --- a/packages/connectors/connector-github/package.json +++ b/packages/connectors/connector-github/package.json @@ -1,11 +1,15 @@ { "name": "@logto/connector-github", - "version": "1.3.0", + "version": "1.4.0", "description": "Github web connector implementation.", "author": "Silverhand Inc. ", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", - "query-string": "^9.0.0" + "@silverhand/essentials": "^2.9.0", + "ky": "^1.2.3", + "query-string": "^9.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -47,5 +51,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "14.0.0-beta.6", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-github/src/constant.ts b/packages/connectors/connector-github/src/constant.ts index 4d9445e16fa..798546e4027 100644 --- a/packages/connectors/connector-github/src/constant.ts +++ b/packages/connectors/connector-github/src/constant.ts @@ -2,9 +2,15 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit'; export const authorizationEndpoint = 'https://github.com/login/oauth/authorize'; -export const scope = 'read:user'; +/** + * `read:user` read user profile data; `user:email` read user email addresses (including private email addresses). + * Ref: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps + */ +export const scope = 'read:user user:email'; export const accessTokenEndpoint = 'https://github.com/login/oauth/access_token'; export const userInfoEndpoint = 'https://api.github.com/user'; +// Ref: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user +export const userEmailsEndpoint = 'https://api.github.com/user/emails'; export const defaultMetadata: ConnectorMetadata = { id: 'github-universal', diff --git a/packages/connectors/connector-github/src/index.test.ts b/packages/connectors/connector-github/src/index.test.ts index 0656245e073..f7d0793ce2f 100644 --- a/packages/connectors/connector-github/src/index.test.ts +++ b/packages/connectors/connector-github/src/index.test.ts @@ -1,9 +1,13 @@ import nock from 'nock'; import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; -import qs from 'query-string'; -import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js'; +import { + accessTokenEndpoint, + authorizationEndpoint, + userEmailsEndpoint, + userInfoEndpoint, +} from './constant.js'; import createConnector, { getAccessToken } from './index.js'; import { mockedConfig } from './mock.js'; @@ -28,7 +32,7 @@ describe('getAuthorizationUri', () => { vi.fn() ); expect(authorizationUri).toEqual( - `${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser` + `${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser+user%3Aemail` ); }); }); @@ -40,16 +44,11 @@ describe('getAccessToken', () => { }); it('should get an accessToken by exchanging with code', async () => { - nock(accessTokenEndpoint) - .post('') - .reply( - 200, - qs.stringify({ - access_token: 'access_token', - scope: 'scope', - token_type: 'token_type', - }) - ); + nock(accessTokenEndpoint).post('').reply(200, { + access_token: 'access_token', + scope: 'scope', + token_type: 'token_type', + }); const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' }); expect(accessToken).toEqual('access_token'); }); @@ -57,7 +56,7 @@ describe('getAccessToken', () => { it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { nock(accessTokenEndpoint) .post('') - .reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' })); + .reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' }); await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toStrictEqual( new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) ); @@ -66,16 +65,11 @@ describe('getAccessToken', () => { describe('getUserInfo', () => { beforeEach(() => { - nock(accessTokenEndpoint) - .post('') - .reply( - 200, - qs.stringify({ - access_token: 'access_token', - scope: 'scope', - token_type: 'token_type', - }) - ); + nock(accessTokenEndpoint).post('').query(true).reply(200, { + access_token: 'access_token', + scope: 'scope', + token_type: 'token_type', + }); }); afterEach(() => { @@ -91,6 +85,7 @@ describe('getUserInfo', () => { email: 'octocat@github.com', foo: 'bar', }); + nock(userEmailsEndpoint).get('').reply(200, []); const connector = await createConnector({ getConfig }); const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn()); expect(socialUserInfo).toStrictEqual({ @@ -99,11 +94,70 @@ describe('getUserInfo', () => { name: 'monalisa octocat', email: 'octocat@github.com', rawData: { - id: 1, - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - name: 'monalisa octocat', - email: 'octocat@github.com', - foo: 'bar', + userInfo: { + id: 1, + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + name: 'monalisa octocat', + email: 'octocat@github.com', + foo: 'bar', + }, + userEmails: [], + }, + }); + }); + + it('should fallback to verified primary email if not public is available', async () => { + nock(userInfoEndpoint).get('').reply(200, { + id: 1, + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + name: 'monalisa octocat', + email: undefined, + foo: 'bar', + }); + nock(userEmailsEndpoint) + .get('') + .reply(200, [ + { + email: 'foo@logto.io', + verified: true, + primary: true, + visibility: 'public', + }, + { + email: 'foo1@logto.io', + verified: true, + primary: false, + visibility: null, + }, + ]); + const connector = await createConnector({ getConfig }); + const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn()); + expect(socialUserInfo).toStrictEqual({ + id: '1', + avatar: 'https://github.com/images/error/octocat_happy.gif', + name: 'monalisa octocat', + email: 'foo@logto.io', + rawData: { + userInfo: { + id: 1, + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + name: 'monalisa octocat', + foo: 'bar', + }, + userEmails: [ + { + email: 'foo@logto.io', + verified: true, + primary: true, + visibility: 'public', + }, + { + email: 'foo1@logto.io', + verified: true, + primary: false, + visibility: null, + }, + ], }, }); }); @@ -115,15 +169,14 @@ describe('getUserInfo', () => { name: null, email: null, }); + nock(userEmailsEndpoint).get('').reply(200, []); const connector = await createConnector({ getConfig }); const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn()); expect(socialUserInfo).toMatchObject({ id: '1', rawData: { - id: 1, - avatar_url: null, - name: null, - email: null, + userInfo: { id: 1, avatar_url: null, name: null, email: null }, + userEmails: [], }, }); }); diff --git a/packages/connectors/connector-github/src/index.ts b/packages/connectors/connector-github/src/index.ts index 52a0e97f182..8c36bdc36ba 100644 --- a/packages/connectors/connector-github/src/index.ts +++ b/packages/connectors/connector-github/src/index.ts @@ -1,6 +1,12 @@ import { assert, conditional } from '@silverhand/essentials'; -import { got, HTTPError } from 'got'; +import { + ConnectorError, + ConnectorErrorCodes, + validateConfig, + ConnectorType, + jsonGuard, +} from '@logto/connector-kit'; import type { GetAuthorizationUri, GetUserInfo, @@ -8,20 +14,14 @@ import type { CreateConnector, GetConnectorConfig, } from '@logto/connector-kit'; -import { - ConnectorError, - ConnectorErrorCodes, - validateConfig, - ConnectorType, - parseJson, -} from '@logto/connector-kit'; -import qs from 'query-string'; +import ky, { HTTPError } from 'ky'; import { authorizationEndpoint, accessTokenEndpoint, scope as defaultScope, userInfoEndpoint, + userEmailsEndpoint, defaultMetadata, defaultTimeout, } from './constant.js'; @@ -29,6 +29,7 @@ import type { GithubConfig } from './types.js'; import { authorizationCallbackErrorGuard, githubConfigGuard, + emailAddressGuard, accessTokenResponseGuard, userInfoResponseGuard, authResponseGuard, @@ -79,17 +80,18 @@ export const getAccessToken = async (config: GithubConfig, codeObject: { code: s const { code } = codeObject; const { clientId: client_id, clientSecret: client_secret } = config; - const httpResponse = await got.post({ - url: accessTokenEndpoint, - json: { - client_id, - client_secret, - code, - }, - timeout: { request: defaultTimeout }, - }); + const httpResponse = await ky + .post(accessTokenEndpoint, { + body: new URLSearchParams({ + client_id, + client_secret, + code, + }), + timeout: defaultTimeout, + }) + .json(); - const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body)); + const result = accessTokenResponseGuard.safeParse(httpResponse); if (!result.success) { throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); @@ -110,34 +112,54 @@ const getUserInfo = validateConfig(config, githubConfigGuard); const { accessToken } = await getAccessToken(config, { code }); + const authedApi = ky.create({ + timeout: defaultTimeout, + hooks: { + beforeRequest: [ + (request) => { + request.headers.set('Authorization', `Bearer ${accessToken}`); + }, + ], + }, + }); + try { - const httpResponse = await got.get(userInfoEndpoint, { - headers: { - authorization: `token ${accessToken}`, - }, - timeout: { request: defaultTimeout }, - }); - const rawData = parseJson(httpResponse.body); - const result = userInfoResponseGuard.safeParse(rawData); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + const [userInfo, userEmails] = await Promise.all([ + authedApi.get(userInfoEndpoint).json(), + authedApi.get(userEmailsEndpoint).json(), + ]); + + const userInfoResult = userInfoResponseGuard.safeParse(userInfo); + const userEmailsResult = emailAddressGuard.array().safeParse(userEmails); + + if (!userInfoResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userInfoResult.error); + } + + if (!userEmailsResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userEmailsResult.error); } - const { id, avatar_url: avatar, email, name } = result.data; + const { id, avatar_url: avatar, email: publicEmail, name } = userInfoResult.data; return { id: String(id), avatar: conditional(avatar), - email: conditional(email), + email: conditional( + publicEmail ?? + userEmailsResult.data.find(({ verified, primary }) => verified && primary)?.email + ), name: conditional(name), - rawData, + rawData: jsonGuard.parse({ + userInfo, + userEmails, + }), }; } catch (error: unknown) { if (error instanceof HTTPError) { - const { statusCode, body: rawBody } = error.response; + const { status, body: rawBody } = error.response; - if (statusCode === 401) { + if (status === 401) { throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); } diff --git a/packages/connectors/connector-github/src/types.ts b/packages/connectors/connector-github/src/types.ts index fcd8c30915a..7906e4e8be9 100644 --- a/packages/connectors/connector-github/src/types.ts +++ b/packages/connectors/connector-github/src/types.ts @@ -8,6 +8,17 @@ export const githubConfigGuard = z.object({ export type GithubConfig = z.infer; +/** + * This guard is used to validate the response from the GitHub API when requesting the user's email addresses. + * Ref: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user + */ +export const emailAddressGuard = z.object({ + email: z.string(), + primary: z.boolean(), + verified: z.boolean(), + visibility: z.string().nullable(), +}); + export const accessTokenResponseGuard = z.object({ access_token: z.string(), scope: z.string(), diff --git a/packages/connectors/connector-google/package.json b/packages/connectors/connector-google/package.json index cb5dee0d232..a837908f041 100644 --- a/packages/connectors/connector-google/package.json +++ b/packages/connectors/connector-google/package.json @@ -4,7 +4,11 @@ "description": "Google web connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-huggingface/CHANGELOG.md b/packages/connectors/connector-huggingface/CHANGELOG.md new file mode 100644 index 00000000000..a64bffe3fd9 --- /dev/null +++ b/packages/connectors/connector-huggingface/CHANGELOG.md @@ -0,0 +1,12 @@ +# @logto/connector-huggingface + +## 0.1.0 + +### Minor Changes + +- 3e5ffc499: add Hugging Face social connector + +### Patch Changes + +- Updated dependencies [f9c7a72d5] + - @logto/connector-oauth@1.3.0 diff --git a/packages/connectors/connector-huggingface/README.md b/packages/connectors/connector-huggingface/README.md new file mode 100644 index 00000000000..4a0b20234d8 --- /dev/null +++ b/packages/connectors/connector-huggingface/README.md @@ -0,0 +1,64 @@ +# Hugging Face connector + +The official Logto connector for Hugging Face social sign-in. + +**Table of contents** + +- [Hugging Face connector](#hugging-face-connector) + - [Get started](#get-started) + - [Sign in with Hugging Face account](#sign-in-with-hugging-face-account) + - [Create an OAuth app in the Hugging Face](#create-an-oauth-app-in-the-hugging-face) + - [Managing Hugging Face OAuth apps](#managing-hugging-face-oauth-apps) + - [Configure your connector](#configure-your-connector) + - [Config types](#config-types) + - [Test Hugging Face connector](#test-hugging-face-connector) + - [Reference](#reference) + + +## Get started + +The Hugging Face connector enables end-users to sign in to your application using their own Hugging Face accounts via Hugging Face OAuth / OpenID connect flow. + +## Sign in with Hugging Face account + +Go to the [Hugging Face website](https://huggingface.co/) and sign in with your Hugging Face account. You may register a new account if you don't have one. + +## Create an OAuth app in the Hugging Face + +Follow the [Creating an oauth app](https://huggingface.co/docs/hub/en/oauth#creating-an-oauth-app) guide, and register a new application. + +In the creation process, you will need to provide the following information: + +- **Application Name**: The name of your application. +- **Homepage URL**: The URL of your application's homepage or landing page. +- **Logo URL**: The URL of your application's logo. +- **Scopes**: The scopes allowed for the OAuth app. For Hugging Face connector, usually use `profile` to get the user's profile information and `email` to get the user's email address. Ensure these scopes are allowed in your Hugging Face OAuth app if you want to use them. +- **Redirect URI**: The URL to redirect the user to after they have authenticated. You can find the redirect URI in the Logto Admin Console when you're creating a Hugging Face connector or in the created Hugging Face connector details page. + +## Managing Hugging Face OAuth apps + +Go to the [Connected Applications](https://huggingface.co/settings/connected-applications) page, you can add, edit or delete existing OAuth apps. +You can also find `Client ID` and generate `App secrets` in corresponding OAuth app settings pages. + +## Configure your connector + +Fill out the `clientId` and `clientSecret` field with _Client ID_ and _App Secret_ you've got from OAuth app detail pages mentioned in the previous section. + +`scope` is a space-delimited list of [Hugging Face supported scopes](https://huggingface.co/docs/hub/en/oauth#currently-supported-scopes). If not provided, scope defaults to be `profile`. For Hugging Face connector, the scope you may want to use is `profile` and `email`. `profile` scope is required to get the user's profile information, and `email` scope is required to get the user's email address. Ensure you have allowed these scopes in your Hugging Face OAuth app (configured in [Create an OAuth app in the Hugging Face](#create-an-oauth-app-in-the-hugging-face) section). + +### Config types + +| Name | Type | +|--------------|--------| +| clientId | string | +| clientSecret | string | +| scope | string | + + +## Test Hugging Face connector + +That's it. The Hugging Face connector should be available now. Don't forget to [Enable connector in sign-in experience](https://docs.logto.io/docs/recipes/configure-connectors/social-connector/enable-social-sign-in/). + +## Reference + +- [Hugging Face - Sign in with Hugging Face](https://huggingface.co/docs/hub/en/oauth#sign-in-with-hugging-face) diff --git a/packages/connectors/connector-huggingface/logo.svg b/packages/connectors/connector-huggingface/logo.svg new file mode 100644 index 00000000000..954043a4e4e --- /dev/null +++ b/packages/connectors/connector-huggingface/logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/connectors/connector-huggingface/package.json b/packages/connectors/connector-huggingface/package.json new file mode 100644 index 00000000000..ef45e49e5a1 --- /dev/null +++ b/packages/connectors/connector-huggingface/package.json @@ -0,0 +1,74 @@ +{ + "name": "@logto/connector-huggingface", + "version": "0.1.0", + "description": "Hugging Face connector implementation.", + "author": "Silverhand Inc. ", + "dependencies": { + "@logto/connector-kit": "workspace:^3.0.0", + "@logto/connector-oauth": "workspace:^1.3.0", + "@silverhand/essentials": "^2.9.0", + "ky": "^1.2.3", + "zod": "^3.22.4" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "exports": "./lib/index.js", + "license": "MPL-2.0", + "type": "module", + "files": [ + "lib", + "docs", + "logo.svg", + "logo-dark.svg" + ], + "scripts": { + "precommit": "lint-staged", + "build:test": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test": "vitest src", + "test:ci": "pnpm run test --silent --coverage", + "prepublishOnly": "pnpm build" + }, + "engines": { + "node": "^20.9.0" + }, + "eslintConfig": { + "extends": "@silverhand", + "settings": { + "import/core-modules": [ + "@silverhand/essentials", + "got", + "nock", + "snakecase-keys", + "zod" + ] + } + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "14.0.0-beta.6", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" + } +} diff --git a/packages/connectors/connector-huggingface/src/constant.ts b/packages/connectors/connector-huggingface/src/constant.ts new file mode 100644 index 00000000000..0d33191a6f3 --- /dev/null +++ b/packages/connectors/connector-huggingface/src/constant.ts @@ -0,0 +1,33 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorPlatform } from '@logto/connector-kit'; +import { clientIdFormItem, clientSecretFormItem, scopeFormItem } from '@logto/connector-oauth'; + +export const authorizationEndpoint = 'https://huggingface.co/oauth/authorize'; +export const tokenEndpoint = 'https://huggingface.co/oauth/token'; +export const userInfoEndpoint = 'https://huggingface.co/oauth/userinfo'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'huggingface-universal', + target: 'huggingface', + platform: ConnectorPlatform.Universal, + name: { + en: 'Hugging Face', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'Hugging Face is a machine learning (ML) and data science platform and community that helps users build, deploy and train machine learning models.', + }, + readme: './README.md', + formItems: [ + clientIdFormItem, + clientSecretFormItem, + { + ...scopeFormItem, + description: + "`profile` is required to get user's profile information, `email` is required to get user's email address. These scopes can be used individually or in combination; if no scopes are specified, `profile` will be used by default.", + }, + ], +}; + +export const defaultTimeout = 5000; diff --git a/packages/connectors/connector-huggingface/src/index.test.ts b/packages/connectors/connector-huggingface/src/index.test.ts new file mode 100644 index 00000000000..e0b79e4904c --- /dev/null +++ b/packages/connectors/connector-huggingface/src/index.test.ts @@ -0,0 +1,133 @@ +import nock from 'nock'; + +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; + +import { authorizationEndpoint, tokenEndpoint, userInfoEndpoint } from './constant.js'; +import createConnector from './index.js'; + +const getConfig = vi.fn().mockResolvedValue({ + clientId: '', + clientSecret: '', + scope: 'profile email', +}); + +const getSessionMock = vi.fn().mockResolvedValue({ redirectUri: 'http://localhost:3000/callback' }); + +describe('Hugging Face connector', () => { + beforeEach(() => { + nock(tokenEndpoint).post('').reply(200, { + access_token: 'access_token', + scope: 'scope', + token_type: 'token_type', + }); + }); + + afterEach(() => { + nock.cleanAll(); + vi.clearAllMocks(); + }); + + it('should get a valid uri by redirectUri and state', async () => { + const connector = await createConnector({ getConfig }); + const authorizationUri = await connector.getAuthorizationUri( + { + state: 'some_state', + redirectUri: 'http://localhost:3000/callback', + connectorId: 'some_connector_id', + connectorFactoryId: 'some_connector_factory_id', + jti: 'some_jti', + headers: {}, + }, + vi.fn() + ); + + expect(authorizationUri).toEqual( + `${authorizationEndpoint}?${new URLSearchParams({ + response_type: 'code', + client_id: '', + scope: 'profile email', + redirect_uri: 'http://localhost:3000/callback', + state: 'some_state', + }).toString()}` + ); + }); + + it('should get valid SocialUserInfo', async () => { + nock(userInfoEndpoint).get('').reply(200, { + sub: 'id', + name: 'name', + email: 'email', + picture: 'picture', + }); + + const connector = await createConnector({ getConfig }); + const socialUserInfo = await connector.getUserInfo({ code: 'code' }, getSessionMock); + + expect(socialUserInfo).toStrictEqual({ + id: 'id', + avatar: 'picture', + name: 'name', + email: 'email', + rawData: { + sub: 'id', + name: 'name', + email: 'email', + picture: 'picture', + }, + }); + }); + + it('throws AuthorizationFailed error if authentication failed', async () => { + const connector = await createConnector({ getConfig }); + await expect( + connector.getUserInfo({ error: 'some error' }, getSessionMock) + ).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, { error: 'some error' }) + ); + }); + + it('throws InvalidResponse error if token response is invalid', async () => { + // Clear token response mock + nock.cleanAll(); + + nock(tokenEndpoint).post('').reply(200, { + invalid_filed: true, + }); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy( + (connectorError) => + (connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse + ); + }); + + it('throws InvalidResponse error if userinfo response is invalid', async () => { + nock(userInfoEndpoint).get('').reply(200, { + id: 'id', + }); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toSatisfy( + (connectorError) => + (connectorError as ConnectorError).code === ConnectorErrorCodes.InvalidResponse + ); + }); + + it('throws SocialAccessTokenInvalid error if user info responded with 401', async () => { + nock(userInfoEndpoint).get('').reply(401); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); + + it('throws General error if user info responded with a non-401 error', async () => { + nock(userInfoEndpoint).get('').reply(422); + + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, getSessionMock)).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.General) + ); + }); +}); diff --git a/packages/connectors/connector-huggingface/src/index.ts b/packages/connectors/connector-huggingface/src/index.ts new file mode 100644 index 00000000000..ad86f987de7 --- /dev/null +++ b/packages/connectors/connector-huggingface/src/index.ts @@ -0,0 +1,154 @@ +import { conditional, assert } from '@silverhand/essentials'; + +import type { + GetAuthorizationUri, + GetUserInfo, + SocialConnector, + CreateConnector, + GetConnectorConfig, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + validateConfig, + ConnectorType, + parseJson, +} from '@logto/connector-kit'; +import { + constructAuthorizationUri, + oauth2AuthResponseGuard, + requestTokenEndpoint, + TokenEndpointAuthMethod, +} from '@logto/connector-oauth'; +import ky, { HTTPError } from 'ky'; + +import { + authorizationEndpoint, + userInfoEndpoint, + defaultMetadata, + defaultTimeout, + tokenEndpoint, +} from './constant.js'; +import { + accessTokenResponseGuard, + huggingfaceConnectorConfigGuard, + userInfoResponseGuard, +} from './types.js'; + +const getAuthorizationUri = + (getConfig: GetConnectorConfig): GetAuthorizationUri => + async ({ state, redirectUri }, setSession) => { + const config = await getConfig(defaultMetadata.id); + validateConfig(config, huggingfaceConnectorConfigGuard); + + const { clientId, scope } = config; + + await setSession({ redirectUri }); + + return constructAuthorizationUri(authorizationEndpoint, { + responseType: 'code', + clientId, + scope: scope ?? 'profile', // Defaults to 'profile' if not provided + redirectUri, + state, + }); + }; + +const getUserInfo = + (getConfig: GetConnectorConfig): GetUserInfo => + async (data, getSession) => { + const authResponseResult = oauth2AuthResponseGuard.safeParse(data); + + if (!authResponseResult.success) { + throw new ConnectorError(ConnectorErrorCodes.AuthorizationFailed, data); + } + + const { code } = authResponseResult.data; + + const config = await getConfig(defaultMetadata.id); + validateConfig(config, huggingfaceConnectorConfigGuard); + + const { clientId, clientSecret } = config; + const { redirectUri } = await getSession(); + + if (!redirectUri) { + throw new ConnectorError(ConnectorErrorCodes.General, { + message: 'Cannot find `redirectUri` from connector session.', + }); + } + + const tokenResponse = await requestTokenEndpoint({ + tokenEndpoint, + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretBasic, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code, + redirectUri, + clientId, + clientSecret, + }, + }); + + const parsedTokenResponse = accessTokenResponseGuard.safeParse(await tokenResponse.json()); + + if (!parsedTokenResponse.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsedTokenResponse.error); + } + + const { access_token: accessToken, token_type: tokenType } = parsedTokenResponse.data; + + assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)); + + try { + const userInfoResponse = await ky.get(userInfoEndpoint, { + headers: { + authorization: `${tokenType} ${accessToken}`, + }, + timeout: defaultTimeout, + }); + + const rawData = parseJson(await userInfoResponse.text()); + + const parsedUserInfoResponse = userInfoResponseGuard.safeParse(rawData); + + if (!parsedUserInfoResponse.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, parsedUserInfoResponse.error); + } + + const { sub, picture, email, name } = parsedUserInfoResponse.data; + + return { + id: sub, + avatar: conditional(picture), + email: conditional(email), + name: conditional(name), + rawData, + }; + } catch (error: unknown) { + if (error instanceof HTTPError) { + const { response } = error; + + if (response.status === 401) { + throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); + } + + throw new ConnectorError(ConnectorErrorCodes.General, await response.text()); + } + + throw error; + } + }; + +const createHuggingfaceConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Social, + configGuard: huggingfaceConnectorConfigGuard, + getAuthorizationUri: getAuthorizationUri(getConfig), + getUserInfo: getUserInfo(getConfig), + }; +}; + +export default createHuggingfaceConnector; \ No newline at end of file diff --git a/packages/connectors/connector-huggingface/src/types.ts b/packages/connectors/connector-huggingface/src/types.ts new file mode 100644 index 00000000000..f82ea230b34 --- /dev/null +++ b/packages/connectors/connector-huggingface/src/types.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +import { oauth2ConfigGuard } from '@logto/connector-oauth'; + +export const huggingfaceConnectorConfigGuard = oauth2ConfigGuard.pick({ + clientId: true, + clientSecret: true, + scope: true, +}); + +export type HuggingfaceConnectorConfigGuard = z.infer; + +export const accessTokenResponseGuard = z.object({ + access_token: z.string(), + scope: z.string(), + token_type: z.string(), +}); + +export type AccessTokenResponse = z.infer; + +export const userInfoResponseGuard = z.object({ + sub: z.string(), + name: z.string().optional().nullable(), + picture: z.string().optional().nullable(), + email: z.string().optional().nullable(), +}); + +export type UserInfoResponse = z.infer; + +export const authorizationCallbackErrorGuard = z.object({ + error: z.string(), + error_description: z.string(), + error_uri: z.string(), +}); + +export const authResponseGuard = z.object({ code: z.string() }); \ No newline at end of file diff --git a/packages/connectors/connector-kakao/package.json b/packages/connectors/connector-kakao/package.json index b89bdb04999..af1be48d8c5 100644 --- a/packages/connectors/connector-kakao/package.json +++ b/packages/connectors/connector-kakao/package.json @@ -4,7 +4,11 @@ "description": "Kakao connector implementation.", "author": "Kyungyoon Kim. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-logto-email/package.json b/packages/connectors/connector-logto-email/package.json index fb5f6d8a668..8ecae3af35f 100644 --- a/packages/connectors/connector-logto-email/package.json +++ b/packages/connectors/connector-logto-email/package.json @@ -4,7 +4,11 @@ "description": "Logto email connector.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -48,6 +52,24 @@ "access": "public" }, "devDependencies": { - "@logto/cloud": "0.2.5-ab8a489" + "@logto/cloud": "0.2.5-e5d8200", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-logto-sms/package.json b/packages/connectors/connector-logto-sms/package.json index 2a81c137182..aa97290af74 100644 --- a/packages/connectors/connector-logto-sms/package.json +++ b/packages/connectors/connector-logto-sms/package.json @@ -4,7 +4,11 @@ "description": "Logto SMS connector.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-logto-social-demo/package.json b/packages/connectors/connector-logto-social-demo/package.json index 0a12e00a09b..3434cd239c2 100644 --- a/packages/connectors/connector-logto-social-demo/package.json +++ b/packages/connectors/connector-logto-social-demo/package.json @@ -4,7 +4,11 @@ "description": "OAuth standard connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-mailgun/package.json b/packages/connectors/connector-mailgun/package.json index 9345a26c89e..4ae0879adde 100644 --- a/packages/connectors/connector-mailgun/package.json +++ b/packages/connectors/connector-mailgun/package.json @@ -4,7 +4,11 @@ "description": "Mailgun connector for Logto.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-mock-email-alternative/package.json b/packages/connectors/connector-mock-email-alternative/package.json index b44be9b87ea..665d563c533 100644 --- a/packages/connectors/connector-mock-email-alternative/package.json +++ b/packages/connectors/connector-mock-email-alternative/package.json @@ -4,7 +4,11 @@ "description": "Mock Standard Email Service connector implementation for integration tests only.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "scripts": { "precommit": "lint-staged", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-mock-email/package.json b/packages/connectors/connector-mock-email/package.json index 937c34f1580..e5115863583 100644 --- a/packages/connectors/connector-mock-email/package.json +++ b/packages/connectors/connector-mock-email/package.json @@ -4,7 +4,11 @@ "description": "Mock Email Service connector implementation for integration tests only.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "scripts": { "precommit": "lint-staged", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-mock-sms/package.json b/packages/connectors/connector-mock-sms/package.json index 46a0a74bb2d..b495e3a8c5e 100644 --- a/packages/connectors/connector-mock-sms/package.json +++ b/packages/connectors/connector-mock-sms/package.json @@ -4,7 +4,11 @@ "description": "Mock SMS connector implementation for integration tests only.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "scripts": { "precommit": "lint-staged", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-mock-social/package.json b/packages/connectors/connector-mock-social/package.json index 4726bd25e47..3ed7a26b15a 100644 --- a/packages/connectors/connector-mock-social/package.json +++ b/packages/connectors/connector-mock-social/package.json @@ -4,7 +4,11 @@ "description": "Social mock connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "scripts": { "precommit": "lint-staged", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-naver/package.json b/packages/connectors/connector-naver/package.json index 09fa173db46..0de8c44c93f 100644 --- a/packages/connectors/connector-naver/package.json +++ b/packages/connectors/connector-naver/package.json @@ -4,7 +4,11 @@ "description": "Naver connector implementation.", "author": "Kyungyoon Kim. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-oauth2/CHANGELOG.md b/packages/connectors/connector-oauth2/CHANGELOG.md index b8f64dd545f..a1f37599889 100644 --- a/packages/connectors/connector-oauth2/CHANGELOG.md +++ b/packages/connectors/connector-oauth2/CHANGELOG.md @@ -1,5 +1,16 @@ # @logto/connector-oauth +## 1.3.0 + +### Minor Changes + +- f9c7a72d5: Support `client_secret_basic` and `client_secret_jwt` token endpoint auth method for oauth & oidc connectors + +### Patch Changes + +- Updated dependencies [21bb35b12] + - @logto/shared@3.1.1 + ## 1.2.0 ### Minor Changes diff --git a/packages/connectors/connector-oauth2/README.md b/packages/connectors/connector-oauth2/README.md index 9098feaa73e..16263a1fd76 100644 --- a/packages/connectors/connector-oauth2/README.md +++ b/packages/connectors/connector-oauth2/README.md @@ -24,6 +24,10 @@ We ONLY support "Authorization Code" grant type for security consideration and i *clientSecret*: The client secret is a confidential key that is issued to the client application by the authorization server during registration. The client application uses this secret key to authenticate itself with the authorization server when requesting access tokens. The client secret is considered confidential information and should be kept secure at all times. +*tokenEndpointAuthMethod*: The token endpoint authentication method is used by the client application to authenticate itself with the authorization server when requesting access tokens. To discover supported methods, consult the `token_endpoint_auth_methods_supported` field available at the OAuth 2.0 service provider’s OpenID Connect discovery endpoint, or refer to the relevant documentation provided by the OAuth 2.0 service provider. + +*clientSecretJwtSigningAlgorithm (Optional)*: Only required when `tokenEndpointAuthMethod` is `client_secret_jwt`. The client secret JWT signing algorithm is used by the client application to sign the JWT that is sent to the authorization server during the token request. + *scope*: The scope parameter is used to specify the set of resources and permissions that the client application is requesting access to. The scope parameter is typically defined as a space-separated list of values that represent specific permissions. For example, a scope value of "read write" might indicate that the client application is requesting read and write access to a user's data. You are expected to find `authorizationEndpoint`, `tokenEndpoint` and `userInfoEndpoint` in social vendor's documentation. diff --git a/packages/connectors/connector-oauth2/package.json b/packages/connectors/connector-oauth2/package.json index 7b73b7e8b90..1049718658d 100644 --- a/packages/connectors/connector-oauth2/package.json +++ b/packages/connectors/connector-oauth2/package.json @@ -1,11 +1,17 @@ { "name": "@logto/connector-oauth", - "version": "1.2.0", + "version": "1.3.0", "description": "OAuth standard connector implementation.", "author": "Silverhand Inc. ", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", - "query-string": "^9.0.0" + "@logto/shared": "workspace:^3.1.1", + "@silverhand/essentials": "^2.9.0", + "jose": "^5.0.0", + "ky": "^1.2.3", + "query-string": "^9.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -27,7 +33,8 @@ "lint:report": "pnpm lint --format json --output-file report.json", "test": "vitest src", "test:ci": "pnpm run test --silent --coverage", - "prepublishOnly": "pnpm build" + "prepublishOnly": "pnpm build", + "prepack": "pnpm build" }, "engines": { "node": "^20.9.0" @@ -47,5 +54,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "14.0.0-beta.6", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-oauth2/src/constant.ts b/packages/connectors/connector-oauth2/src/constant.ts index 4624d338a5f..cd07445dded 100644 --- a/packages/connectors/connector-oauth2/src/constant.ts +++ b/packages/connectors/connector-oauth2/src/constant.ts @@ -1,6 +1,15 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit'; +import { + authorizationEndpointFormItem, + clientIdFormItem, + clientSecretFormItem, + scopeFormItem, + tokenEndpointAuthOptionsFormItems, + tokenEndpointFormItem, +} from './oauth2/form-items.js'; + export const defaultMetadata: ConnectorMetadata = { id: 'oauth2', target: 'oauth2', @@ -18,20 +27,8 @@ export const defaultMetadata: ConnectorMetadata = { readme: './README.md', isStandard: true, formItems: [ - { - key: 'authorizationEndpoint', - label: 'Authorization Endpoint', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'tokenEndpoint', - label: 'Token Endpoint', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, + authorizationEndpointFormItem, + tokenEndpointFormItem, { key: 'userInfoEndpoint', label: 'User Info Endpoint', @@ -39,20 +36,9 @@ export const defaultMetadata: ConnectorMetadata = { required: true, placeholder: '', }, - { - key: 'clientId', - label: 'Client ID', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'clientSecret', - label: 'Client Secret', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, + clientIdFormItem, + clientSecretFormItem, + ...tokenEndpointAuthOptionsFormItems, { key: 'tokenEndpointResponseType', label: 'Token Endpoint Response Type', @@ -67,13 +53,7 @@ export const defaultMetadata: ConnectorMetadata = { required: false, defaultValue: 'query-string', }, - { - key: 'scope', - label: 'Scope', - type: ConnectorConfigFormItemType.Text, - required: false, - placeholder: '', - }, + scopeFormItem, { key: 'profileMap', label: 'Profile Map', diff --git a/packages/connectors/connector-oauth2/src/index.ts b/packages/connectors/connector-oauth2/src/index.ts index f2b1c274b1c..6d07368306c 100644 --- a/packages/connectors/connector-oauth2/src/index.ts +++ b/packages/connectors/connector-oauth2/src/index.ts @@ -1,6 +1,4 @@ import { assert, pick } from '@silverhand/essentials'; -import { got, HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; import { type GetAuthorizationUri, @@ -14,45 +12,40 @@ import { validateConfig, ConnectorType, } from '@logto/connector-kit'; +import ky, { HTTPError } from 'ky'; import { defaultMetadata, defaultTimeout } from './constant.js'; -import { oauthConfigGuard } from './types.js'; +import { constructAuthorizationUri } from './oauth2/utils.js'; +import { oauth2ConnectorConfigGuard } from './types.js'; import { userProfileMapping, getAccessToken } from './utils.js'; -const removeUndefinedKeys = (object: Record) => - Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)); +export * from './oauth2/index.js'; const getAuthorizationUri = (getConfig: GetConnectorConfig): GetAuthorizationUri => async ({ state, redirectUri }, setSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oauthConfigGuard); - const parsedConfig = oauthConfigGuard.parse(config); - - const { customConfig, ...rest } = parsedConfig; - - const parameterObject = snakecaseKeys({ - ...pick(rest, 'responseType', 'clientId', 'scope'), - ...customConfig, - }); + validateConfig(config, oauth2ConnectorConfigGuard); + const parsedConfig = oauth2ConnectorConfigGuard.parse(config); await setSession({ redirectUri }); - const queryParameters = new URLSearchParams({ - ...removeUndefinedKeys(parameterObject), + const { authorizationEndpoint, customConfig } = parsedConfig; + + return constructAuthorizationUri(authorizationEndpoint, { + ...pick(parsedConfig, 'responseType', 'clientId', 'scope'), + redirectUri, state, - redirect_uri: redirectUri, + ...customConfig, }); - - return `${parsedConfig.authorizationEndpoint}?${queryParameters.toString()}`; }; const getUserInfo = (getConfig: GetConnectorConfig): GetUserInfo => async (data, getSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oauthConfigGuard); - const parsedConfig = oauthConfigGuard.parse(config); + validateConfig(config, oauth2ConnectorConfigGuard); + const parsedConfig = oauth2ConnectorConfigGuard.parse(config); const { redirectUri } = await getSession(); assert( @@ -65,13 +58,14 @@ const getUserInfo = const { access_token, token_type } = await getAccessToken(parsedConfig, data, redirectUri); try { - const httpResponse = await got.get(parsedConfig.userInfoEndpoint, { + const httpResponse = await ky.get(parsedConfig.userInfoEndpoint, { headers: { authorization: `${token_type} ${access_token}`, }, - timeout: { request: defaultTimeout }, + timeout: defaultTimeout, }); - const rawData = parseJsonObject(httpResponse.body); + + const rawData = parseJsonObject(await httpResponse.text()); return { ...userProfileMapping(rawData, parsedConfig.profileMap), rawData }; } catch (error: unknown) { @@ -87,7 +81,7 @@ const createOauthConnector: CreateConnector = async ({ getConfi return { metadata: defaultMetadata, type: ConnectorType.Social, - configGuard: oauthConfigGuard, + configGuard: oauth2ConnectorConfigGuard, getAuthorizationUri: getAuthorizationUri(getConfig), getUserInfo: getUserInfo(getConfig), }; diff --git a/packages/connectors/connector-oauth2/src/oauth2/form-items.ts b/packages/connectors/connector-oauth2/src/oauth2/form-items.ts new file mode 100644 index 00000000000..3e04565cb73 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/form-items.ts @@ -0,0 +1,96 @@ +import { type ConnectorConfigFormItem, ConnectorConfigFormItemType } from '@logto/connector-kit'; + +import { TokenEndpointAuthMethod, ClientSecretJwtSigningAlgorithm } from './types.js'; + +export const authorizationEndpointFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'authorizationEndpoint', + label: 'Authorization Endpoint', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const tokenEndpointFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'tokenEndpoint', + label: 'Token Endpoint', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const clientIdFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'clientId', + label: 'Client ID', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const clientSecretFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'clientSecret', + label: 'Client Secret', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const tokenEndpointAuthOptionsFormItems: ConnectorConfigFormItem[] = [ + Object.freeze({ + key: 'tokenEndpointAuthMethod', + label: 'Token Endpoint Auth Method', + type: ConnectorConfigFormItemType.Select, + selectItems: [ + { + title: TokenEndpointAuthMethod.ClientSecretPost, + value: TokenEndpointAuthMethod.ClientSecretPost, + }, + { + title: TokenEndpointAuthMethod.ClientSecretBasic, + value: TokenEndpointAuthMethod.ClientSecretBasic, + }, + { + title: TokenEndpointAuthMethod.ClientSecretJwt, + value: TokenEndpointAuthMethod.ClientSecretJwt, + }, + ], + required: true, + defaultValue: TokenEndpointAuthMethod.ClientSecretPost, + description: 'The method used for client authentication at the token endpoint in OAuth 2.0.', + }), + Object.freeze({ + key: 'clientSecretJwtSigningAlgorithm', + label: 'Client Secret JWT Signing Algorithm', + type: ConnectorConfigFormItemType.Select, + selectItems: [ + { + title: ClientSecretJwtSigningAlgorithm.HS256, + value: ClientSecretJwtSigningAlgorithm.HS256, + }, + { + title: ClientSecretJwtSigningAlgorithm.HS384, + value: ClientSecretJwtSigningAlgorithm.HS384, + }, + { + title: ClientSecretJwtSigningAlgorithm.HS512, + value: ClientSecretJwtSigningAlgorithm.HS512, + }, + ], + showConditions: [ + { + targetKey: 'tokenEndpointAuthMethod', + expectValue: TokenEndpointAuthMethod.ClientSecretJwt, + }, + ], + required: true, + defaultValue: ClientSecretJwtSigningAlgorithm.HS256, + description: 'The signing algorithm used for the client secret JWT.', + }), +]; + +export const scopeFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'scope', + label: 'Scope', + type: ConnectorConfigFormItemType.Text, + required: false, + placeholder: '', +}); diff --git a/packages/connectors/connector-oauth2/src/oauth2/index.ts b/packages/connectors/connector-oauth2/src/oauth2/index.ts new file mode 100644 index 00000000000..6449585de66 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './utils.js'; +export * from './form-items.js'; diff --git a/packages/connectors/connector-oauth2/src/oauth2/types.ts b/packages/connectors/connector-oauth2/src/oauth2/types.ts new file mode 100644 index 00000000000..6c9e8c47331 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/types.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +/** + * OAuth 2.0 Client Authentication methods that are used by Clients to authenticate to the Authorization Server when using the Token Endpoint. + */ +export enum TokenEndpointAuthMethod { + ClientSecretBasic = 'client_secret_basic', + ClientSecretPost = 'client_secret_post', + ClientSecretJwt = 'client_secret_jwt', +} + +/* + * Enumeration of algorithms supported for JWT signing when using client secrets. + * + * These "HS" algorithms (HMAC using SHA) are specifically chosen for scenarios where the + * client authentication method is 'client_secret_jwt'. HMAC algorithms utilize the + * client_secret as a shared symmetric key to generate a secure hash, ensuring the integrity + * and authenticity of the JWT. + * + * Other types of algorithms, such as RSASSA (RS256, RS384, RS512) or ECDSA (ES256, ES384, ES512), + * utilize asymmetric keys, are complex and requires secure key management infrastructure. + * + * In the 'client_secret_jwt' context, where simplicity and symmetric key usage are preferred for + * straightforward validation by the authorization server without the need to manage or distribute + * public keys, HMAC algorithms are more suitable. + */ +export enum ClientSecretJwtSigningAlgorithm { + /** HMAC using SHA-256 hash algorithm */ + HS256 = 'HS256', + /** HMAC using SHA-384 hash algorithm */ + HS384 = 'HS384', + /** HMAC using SHA-512 hash algorithm */ + HS512 = 'HS512', +} + +export const oauth2ConfigGuard = z.object({ + responseType: z.literal('code').optional().default('code'), + grantType: z.literal('authorization_code').optional().default('authorization_code'), + authorizationEndpoint: z.string(), + tokenEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + tokenEndpointAuthMethod: z + .nativeEnum(TokenEndpointAuthMethod) + .optional() + .default(TokenEndpointAuthMethod.ClientSecretPost), + clientSecretJwtSigningAlgorithm: z + .nativeEnum(ClientSecretJwtSigningAlgorithm) + .optional() + .default(ClientSecretJwtSigningAlgorithm.HS256), + scope: z.string().optional(), +}); + +export const oauth2AuthResponseGuard = z.object({ + code: z.string(), + state: z.string().optional(), +}); + +export type Oauth2AuthResponse = z.infer; + +export const oauth2AccessTokenResponseGuard = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number().optional(), + refresh_token: z.string().optional(), +}); + +export type Oauth2AccessTokenResponse = z.infer; diff --git a/packages/connectors/connector-oauth2/src/oauth2/utils.test.ts b/packages/connectors/connector-oauth2/src/oauth2/utils.test.ts new file mode 100644 index 00000000000..e4b7184991c --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/utils.test.ts @@ -0,0 +1,183 @@ +import nock from 'nock'; + +import ky from 'ky'; + +import { ClientSecretJwtSigningAlgorithm, TokenEndpointAuthMethod } from './types.js'; +import { constructAuthorizationUri, type RequestTokenEndpointOptions } from './utils.js'; + +const kyPostMock = vi.spyOn(ky, 'post'); + +vi.mock('jose', () => ({ + SignJWT: vi.fn(() => ({ + setProtectedHeader: vi.fn().mockReturnThis(), + sign: vi.fn().mockResolvedValue('signed-jwt'), + })), +})); + +const { requestTokenEndpoint } = await import('./utils.js'); + +const tokenEndpointUrl = new URL('https://example.com/token'); + +describe('requestTokenEndpoint', () => { + beforeEach(() => { + nock(tokenEndpointUrl.origin) + .post(tokenEndpointUrl.pathname) + .query(true) + .reply( + 200, + JSON.stringify({ + access_token: 'access_token', + token_type: 'bearer', + }) + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + vi.clearAllMocks(); + }); + + it('should handle TokenEndpointAuthMethod.ClientSecretJwt correctly', async () => { + const options: RequestTokenEndpointOptions = { + tokenEndpoint: 'https://example.com/token', + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretJwt, + clientSecretJwtSigningAlgorithm: ClientSecretJwtSigningAlgorithm.HS256, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code: 'authcode123', + redirectUri: 'https://example.com/callback', + clientId: 'client123', + clientSecret: 'secret123', + extraParam: 'extra', + }, + timeout: 5000, + }; + + await requestTokenEndpoint(options); + expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, { + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authcode123', + redirect_uri: 'https://example.com/callback', + extra_param: 'extra', + client_id: 'client123', + client_assertion: 'signed-jwt', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + }), + timeout: 5000, + }); + }); + + it('should handle TokenEndpointAuthMethod.ClientSecretBasic correctly', async () => { + const options: RequestTokenEndpointOptions = { + tokenEndpoint: 'https://example.com/token', + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretBasic, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code: 'authcode123', + redirectUri: 'https://example.com/callback', + clientId: 'client123', + clientSecret: 'secret123', + extraParam: 'extra', + }, + timeout: 5000, + }; + + await requestTokenEndpoint(options); + expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, { + headers: { + Authorization: `Basic ${Buffer.from('client123:secret123').toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authcode123', + redirect_uri: 'https://example.com/callback', + extra_param: 'extra', + }), + timeout: 5000, + }); + }); + + it('should handle TokenEndpointAuthMethod.ClientSecretPost correctly', async () => { + const options: RequestTokenEndpointOptions = { + tokenEndpoint: 'https://example.com/token', + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretPost, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code: 'authcode123', + redirectUri: 'https://example.com/callback', + clientId: 'client123', + clientSecret: 'secret123', + extraParam: 'extra', + }, + timeout: 5000, + }; + + await requestTokenEndpoint(options); + expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, { + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authcode123', + redirect_uri: 'https://example.com/callback', + client_id: 'client123', + client_secret: 'secret123', + extra_param: 'extra', + }), + timeout: 5000, + }); + }); +}); + +describe('constructAuthorizationUri', () => { + it('constructs a valid authorization URL with all parameters', async () => { + const authorizationEndpoint = 'https://example.com/oauth/authorize'; + const queryParameters = { + responseType: 'code', + clientId: 'client123', + scope: 'openid email', + redirectUri: 'https://example.com/callback', + state: 'state123', + }; + + const expectedParams = new URLSearchParams({ + response_type: 'code', + client_id: 'client123', + scope: 'openid email', + redirect_uri: 'https://example.com/callback', + state: 'state123', + }).toString(); + + const result = constructAuthorizationUri(authorizationEndpoint, queryParameters); + expect(result).toBe(`${authorizationEndpoint}?${expectedParams}`); + }); + + it('omits undefined values from the constructed URL', async () => { + const authorizationEndpoint = 'https://example.com/oauth/authorize'; + const queryParameters = { + responseType: 'code', + clientId: 'client123', + redirectUri: 'https://example.com/callback', + state: 'state123', + scope: undefined, // This should not appear in the final URL + }; + + const expectedParams = new URLSearchParams({ + response_type: 'code', + client_id: 'client123', + redirect_uri: 'https://example.com/callback', + state: 'state123', + }).toString(); + + const result = constructAuthorizationUri(authorizationEndpoint, queryParameters); + expect(result).toBe(`${authorizationEndpoint}?${expectedParams}`); + }); +}); diff --git a/packages/connectors/connector-oauth2/src/oauth2/utils.ts b/packages/connectors/connector-oauth2/src/oauth2/utils.ts new file mode 100644 index 00000000000..51f87565f15 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/utils.ts @@ -0,0 +1,151 @@ +import { removeUndefinedKeys } from '@silverhand/essentials'; +import snakecaseKeys from 'snakecase-keys'; + +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; +import { generateStandardId } from '@logto/shared/universal'; +import { SignJWT } from 'jose'; +import ky, { HTTPError } from 'ky'; + +import { TokenEndpointAuthMethod } from './types.js'; + +type TokenEndpointAuthOptions = + T extends TokenEndpointAuthMethod.ClientSecretJwt + ? { + method: TokenEndpointAuthMethod.ClientSecretJwt; + clientSecretJwtSigningAlgorithm: string; + } + : { + method: + | TokenEndpointAuthMethod.ClientSecretBasic + | TokenEndpointAuthMethod.ClientSecretPost; + }; + +export type RequestTokenEndpointOptions = { + tokenEndpoint: string; + tokenEndpointAuthOptions: TokenEndpointAuthOptions; + tokenRequestBody: { + grantType: string; + code: string; + redirectUri: string; + clientId: string; + clientSecret: string; + } & Record; + timeout?: number; +}; + +/** + * Requests the token endpoint for an access token with given client authentication options. + * + * @param tokenEndpoint - The URL of the token endpoint. + * @param clientCredentials - The client credentials (client ID and client secret). + * @param tokenEndpointAuthOptions - The options for authenticating with the token endpoint. + * @param tokenEndpointAuthOptions.method - The method to use for authenticating with the token endpoint. + * @param tokenEndpointAuthOptions.clientSecretJwtSigningAlgorithm - The signing algorithm to use for the client secret JWT. Required if the `method` is `TokenEndpointAuthMethod.ClientSecretJwt`. + * @param tokenRequestBody - The request body to be sent as application/x-www-form-urlencoded to the token endpoint. Parameters are automatically converted to snake_case and undefined values are removed. + * @param timeout - The timeout for the request in milliseconds. + * @returns A Promise that resolves to the response from the token endpoint. + */ +export const requestTokenEndpoint = async ({ + tokenEndpoint, + tokenEndpointAuthOptions, + tokenRequestBody, + timeout, +}: RequestTokenEndpointOptions) => { + const postTokenEndpoint = async ({ + form, + headers, + }: { + form: Record; + headers?: Record; + }) => { + try { + return await ky.post(tokenEndpoint, { + headers, + body: new URLSearchParams(removeUndefinedKeys(snakecaseKeys(form))), + timeout, + }); + } catch (error: unknown) { + if (error instanceof HTTPError) { + throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); + } + + throw error; + } + }; + + const { clientId, clientSecret, ...requestBodyWithoutClientCredentials } = tokenRequestBody; + + switch (tokenEndpointAuthOptions.method) { + case TokenEndpointAuthMethod.ClientSecretJwt: { + const clientSecretJwt = await new SignJWT({ + iss: clientId, + sub: clientId, + aud: tokenEndpoint, + jti: generateStandardId(), + exp: Math.floor(Date.now() / 1000) + 600, // Expiration time is 10 minutes + iat: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ + alg: tokenEndpointAuthOptions.clientSecretJwtSigningAlgorithm, + }) + .sign(Buffer.from(clientSecret)) + .catch((error: unknown) => { + if (error instanceof Error) { + throw new ConnectorError( + ConnectorErrorCodes.General, + 'Failed to sign client secret JWT' + ); + } + throw error; + }); + + return postTokenEndpoint({ + form: { + ...requestBodyWithoutClientCredentials, + clientId, + clientAssertion: clientSecretJwt, + /** + * `client_assertion_type` parameter MUST be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + * see https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 + */ + clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + }, + }); + } + case TokenEndpointAuthMethod.ClientSecretBasic: { + return postTokenEndpoint({ + form: requestBodyWithoutClientCredentials, + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + }, + }); + } + case TokenEndpointAuthMethod.ClientSecretPost: { + return postTokenEndpoint({ + form: tokenRequestBody, + }); + } + } +}; + +/** + * Constructs a complete URL for initiating OAuth authorization by appending properly formatted + * query parameters to the provided authorization endpoint URL. + * + * @param authorizationEndpoint The base URL to which the OAuth authorization request is sent. + * @param queryParameters An object containing OAuth specific parameters such as responseType, clientId, scope, redirectUri, and state. Additional custom parameters can also be included as needed. Parameters are automatically converted to snake_case and undefined values are removed. + * @returns A string representing the fully constructed URL to be used for OAuth authorization. + */ +export const constructAuthorizationUri = ( + authorizationEndpoint: string, + queryParameters: { + responseType: string; + clientId: string; + scope?: string; + redirectUri: string; + state: string; + } & Record +) => + `${authorizationEndpoint}?${new URLSearchParams( + removeUndefinedKeys(snakecaseKeys(queryParameters)) + ).toString()}`; diff --git a/packages/connectors/connector-oauth2/src/types.ts b/packages/connectors/connector-oauth2/src/types.ts index efae11b4c5f..1aa04aaee27 100644 --- a/packages/connectors/connector-oauth2/src/types.ts +++ b/packages/connectors/connector-oauth2/src/types.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { oauth2ConfigGuard } from './oauth2/types.js'; + export const profileMapGuard = z .object({ id: z.string().optional().default('id'), @@ -36,35 +38,11 @@ const tokenEndpointResponseTypeGuard = z export type TokenEndpointResponseType = z.input; -export const oauthConfigGuard = z.object({ - responseType: z.literal('code').optional().default('code'), - grantType: z.literal('authorization_code').optional().default('authorization_code'), - tokenEndpointResponseType: tokenEndpointResponseTypeGuard, - authorizationEndpoint: z.string(), - tokenEndpoint: z.string(), +export const oauth2ConnectorConfigGuard = oauth2ConfigGuard.extend({ userInfoEndpoint: z.string(), - clientId: z.string(), - clientSecret: z.string(), - scope: z.string().optional(), + tokenEndpointResponseType: tokenEndpointResponseTypeGuard, profileMap: profileMapGuard, customConfig: z.record(z.string()).optional(), }); -export type OauthConfig = z.infer; - -export const authResponseGuard = z.object({ - code: z.string(), - state: z.string().optional(), -}); - -export type AuthResponse = z.infer; - -export const accessTokenResponseGuard = z.object({ - access_token: z.string(), - token_type: z.string(), - expires_in: z.number().optional(), - refresh_token: z.string().optional(), - scope: z.string().optional(), -}); - -export type AccessTokenResponse = z.infer; +export type Oauth2ConnectorConfig = z.infer; diff --git a/packages/connectors/connector-oauth2/src/utils.ts b/packages/connectors/connector-oauth2/src/utils.ts index a304854bd7c..18d51f407a4 100644 --- a/packages/connectors/connector-oauth2/src/utils.ts +++ b/packages/connectors/connector-oauth2/src/utils.ts @@ -1,48 +1,25 @@ -import { assert, pick } from '@silverhand/essentials'; -import type { Response } from 'got'; -import { got, HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; +import { assert } from '@silverhand/essentials'; import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; +import { type KyResponse } from 'ky'; import qs from 'query-string'; -import { defaultTimeout } from './constant.js'; -import type { - OauthConfig, - TokenEndpointResponseType, - AccessTokenResponse, - ProfileMap, -} from './types.js'; -import { authResponseGuard, accessTokenResponseGuard, userProfileGuard } from './types.js'; - -export const accessTokenRequester = async ( - tokenEndpoint: string, - queryParameters: Record, - tokenEndpointResponseType: TokenEndpointResponseType, - timeout: number = defaultTimeout -): Promise => { - try { - const httpResponse = await got.post({ - url: tokenEndpoint, - form: queryParameters, - timeout: { request: timeout }, - }); - - return await accessTokenResponseHandler(httpResponse, tokenEndpointResponseType); - } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); - } - throw error; - } -}; +import { + type Oauth2AccessTokenResponse, + oauth2AccessTokenResponseGuard, + oauth2AuthResponseGuard, +} from './oauth2/types.js'; +import { requestTokenEndpoint } from './oauth2/utils.js'; +import type { Oauth2ConnectorConfig, TokenEndpointResponseType, ProfileMap } from './types.js'; +import { userProfileGuard } from './types.js'; const accessTokenResponseHandler = async ( - response: Response, + response: KyResponse, tokenEndpointResponseType: TokenEndpointResponseType -): Promise => { - const result = accessTokenResponseGuard.safeParse( - tokenEndpointResponseType === 'json' ? parseJson(response.body) : qs.parse(response.body) +): Promise => { + const responseContent = await response.text(); + const result = oauth2AccessTokenResponseGuard.safeParse( + tokenEndpointResponseType === 'json' ? parseJson(responseContent) : qs.parse(responseContent) ); // Why it works with qs.parse() if (!result.success) { @@ -84,8 +61,12 @@ export const userProfileMapping = ( return result.data; }; -export const getAccessToken = async (config: OauthConfig, data: unknown, redirectUri: string) => { - const result = authResponseGuard.safeParse(data); +export const getAccessToken = async ( + config: Oauth2ConnectorConfig, + data: unknown, + redirectUri: string +) => { + const result = oauth2AuthResponseGuard.safeParse(data); if (!result.success) { throw new ConnectorError(ConnectorErrorCodes.General, data); @@ -93,18 +74,32 @@ export const getAccessToken = async (config: OauthConfig, data: unknown, redirec const { code } = result.data; - const { customConfig, ...rest } = config; - - const parameterObject = snakecaseKeys({ - ...pick(rest, 'grantType', 'clientId', 'clientSecret'), - ...customConfig, - code, - redirectUri, + const { + grantType, + tokenEndpoint, + tokenEndpointResponseType, + clientId, + clientSecret, + tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + customConfig, + } = config; + + const tokenResponse = await requestTokenEndpoint({ + tokenEndpoint, + tokenEndpointAuthOptions: { + method: tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + }, + tokenRequestBody: { + grantType, + code, + redirectUri, + clientId, + clientSecret, + ...customConfig, + }, }); - return accessTokenRequester( - config.tokenEndpoint, - parameterObject, - config.tokenEndpointResponseType - ); + return accessTokenResponseHandler(tokenResponse, tokenEndpointResponseType); }; diff --git a/packages/connectors/connector-oidc/CHANGELOG.md b/packages/connectors/connector-oidc/CHANGELOG.md index 028aa310b9a..c135a5550d1 100644 --- a/packages/connectors/connector-oidc/CHANGELOG.md +++ b/packages/connectors/connector-oidc/CHANGELOG.md @@ -1,5 +1,18 @@ # @logto/connector-oidc +## 1.3.0 + +### Minor Changes + +- f9c7a72d5: Support `client_secret_basic` and `client_secret_jwt` token endpoint auth method for oauth & oidc connectors + +### Patch Changes + +- Updated dependencies [f9c7a72d5] +- Updated dependencies [21bb35b12] + - @logto/connector-oauth@1.3.0 + - @logto/shared@3.1.1 + ## 1.2.0 ### Minor Changes diff --git a/packages/connectors/connector-oidc/README.md b/packages/connectors/connector-oidc/README.md index eb58ca8e3ab..68bae7495ad 100644 --- a/packages/connectors/connector-oidc/README.md +++ b/packages/connectors/connector-oidc/README.md @@ -24,6 +24,10 @@ We ONLY support "Authorization Code" grant type for security consideration and i *clientSecret*: The client secret is a confidential key that is issued to the client application by the authorization server during registration. The client application uses this secret key to authenticate itself with the authorization server when requesting access tokens. The client secret is considered confidential information and should be kept secure at all times. +*tokenEndpointAuthMethod*: The token endpoint authentication method is used by the client application to authenticate itself with the authorization server when requesting access tokens. To discover supported methods, consult the `token_endpoint_auth_methods_supported` field available at the OAuth 2.0 service provider’s OpenID Connect discovery endpoint, or refer to the relevant documentation provided by the OAuth 2.0 service provider. + +*clientSecretJwtSigningAlgorithm (Optional)*: Only required when `tokenEndpointAuthMethod` is `client_secret_jwt`. The client secret JWT signing algorithm is used by the client application to sign the JWT that is sent to the authorization server during the token request. + *scope*: The scope parameter is used to specify the set of resources and permissions that the client application is requesting access to. The scope parameter is typically defined as a space-separated list of values that represent specific permissions. For example, a scope value of "read write" might indicate that the client application is requesting read and write access to a user's data. You are expected to find `authorizationEndpoint`, `tokenEndpoint`, `jwksUri` and `issuer` as OpenID Provider's configuration information. They should be available in social vendor's documentation. diff --git a/packages/connectors/connector-oidc/package.json b/packages/connectors/connector-oidc/package.json index bd8871732c9..50199345428 100644 --- a/packages/connectors/connector-oidc/package.json +++ b/packages/connectors/connector-oidc/package.json @@ -1,12 +1,17 @@ { "name": "@logto/connector-oidc", - "version": "1.2.0", + "version": "1.3.0", "description": "OIDC standard connector implementation.", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", - "@logto/shared": "workspace:^3.1.0", + "@logto/connector-oauth": "workspace:^1.3.0", + "@logto/shared": "workspace:^3.1.1", + "@silverhand/essentials": "^2.9.0", "jose": "^5.0.0", - "nanoid": "^5.0.1" + "ky": "^1.2.3", + "nanoid": "^5.0.1", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -48,5 +53,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "14.0.0-beta.6", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-oidc/src/constant.ts b/packages/connectors/connector-oidc/src/constant.ts index 2315bc1e4a0..44fc1349a4d 100644 --- a/packages/connectors/connector-oidc/src/constant.ts +++ b/packages/connectors/connector-oidc/src/constant.ts @@ -1,5 +1,13 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit'; +import { + tokenEndpointAuthOptionsFormItems, + clientSecretFormItem, + clientIdFormItem, + tokenEndpointFormItem, + authorizationEndpointFormItem, + scopeFormItem, +} from '@logto/connector-oauth'; export const defaultMetadata: ConnectorMetadata = { id: 'oidc', @@ -18,40 +26,14 @@ export const defaultMetadata: ConnectorMetadata = { readme: './README.md', isStandard: true, formItems: [ + authorizationEndpointFormItem, + tokenEndpointFormItem, + clientIdFormItem, + clientSecretFormItem, + ...tokenEndpointAuthOptionsFormItems, { - key: 'authorizationEndpoint', - label: 'Authorization Endpoint', - type: ConnectorConfigFormItemType.Text, + ...scopeFormItem, required: true, - placeholder: '', - }, - { - key: 'tokenEndpoint', - label: 'Token Endpoint', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'clientId', - label: 'Client ID', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'clientSecret', - label: 'Client Secret', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'scope', - label: 'Scope', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', }, { key: 'idTokenVerificationConfig', diff --git a/packages/connectors/connector-oidc/src/index.ts b/packages/connectors/connector-oidc/src/index.ts index a413a26c06f..a05af49ca09 100644 --- a/packages/connectors/connector-oidc/src/index.ts +++ b/packages/connectors/connector-oidc/src/index.ts @@ -1,6 +1,4 @@ -import { assert, conditional, pick } from '@silverhand/essentials'; -import { HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; +import { assert, conditional } from '@silverhand/essentials'; import type { GetAuthorizationUri, @@ -16,11 +14,13 @@ import { ConnectorType, jsonGuard, } from '@logto/connector-kit'; +import { constructAuthorizationUri } from '@logto/connector-oauth'; import { generateStandardId } from '@logto/shared/universal'; import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { HTTPError } from 'ky'; import { defaultMetadata } from './constant.js'; -import { idTokenProfileStandardClaimsGuard, oidcConfigGuard } from './types.js'; +import { idTokenProfileStandardClaimsGuard, oidcConnectorConfigGuard } from './types.js'; import { getIdToken } from './utils.js'; const generateNonce = () => generateStandardId(); @@ -29,8 +29,8 @@ const getAuthorizationUri = (getConfig: GetConnectorConfig): GetAuthorizationUri => async ({ state, redirectUri }, setSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oidcConfigGuard); - const parsedConfig = oidcConfigGuard.parse(config); + validateConfig(config, oidcConnectorConfigGuard); + const parsedConfig = oidcConnectorConfigGuard.parse(config); const nonce = generateNonce(); @@ -42,28 +42,33 @@ const getAuthorizationUri = ); await setSession({ nonce, redirectUri }); - const { customConfig, authRequestOptionalConfig, ...rest } = parsedConfig; - - const queryParameters = new URLSearchParams({ + const { + authorizationEndpoint, + responseType, + clientId, + scope, + customConfig, + authRequestOptionalConfig, + } = parsedConfig; + + return constructAuthorizationUri(authorizationEndpoint, { + responseType, + clientId, + scope, + redirectUri, state, - ...snakecaseKeys({ - ...pick(rest, 'responseType', 'scope', 'clientId'), - ...authRequestOptionalConfig, - ...customConfig, - }), nonce, - redirect_uri: redirectUri, + ...authRequestOptionalConfig, + ...customConfig, }); - - return `${parsedConfig.authorizationEndpoint}?${queryParameters.toString()}`; }; const getUserInfo = (getConfig: GetConnectorConfig): GetUserInfo => async (data, getSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oidcConfigGuard); - const parsedConfig = oidcConfigGuard.parse(config); + validateConfig(config, oidcConnectorConfigGuard); + const parsedConfig = oidcConnectorConfigGuard.parse(config); assert( getSession, @@ -153,7 +158,7 @@ const createOidcConnector: CreateConnector = async ({ getConfig return { metadata: defaultMetadata, type: ConnectorType.Social, - configGuard: oidcConfigGuard, + configGuard: oidcConnectorConfigGuard, getAuthorizationUri: getAuthorizationUri(getConfig), getUserInfo: getUserInfo(getConfig), }; diff --git a/packages/connectors/connector-oidc/src/types.ts b/packages/connectors/connector-oidc/src/types.ts index 005891e1ec6..432a43af916 100644 --- a/packages/connectors/connector-oidc/src/types.ts +++ b/packages/connectors/connector-oidc/src/types.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; -const scopeOpenid = 'openid' as const; +import { oauth2ConfigGuard } from '@logto/connector-oauth'; + +const scopeOpenid = 'openid'; export const delimiter = /[ +]/; // Space-delimited 'scope' MUST contain 'openid', see https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth @@ -38,16 +40,6 @@ export const userProfileGuard = z.object({ export type UserProfile = z.infer; -const endpointConfigObject = { - authorizationEndpoint: z.string(), - tokenEndpoint: z.string(), -}; - -const clientConfigObject = { - clientId: z.string(), - clientSecret: z.string(), -}; - /** * We remove `nonce` in `authRequestOptionalConfigGuard` because it should be a randomly generated string, * should not be fixed in config and will be generated in Logto core according to `response_type` of authorization request. @@ -84,18 +76,15 @@ export const idTokenVerificationConfigGuard = z.object({ jwksUri: z.string() }). export type IdTokenVerificationConfig = z.infer; -export const oidcConfigGuard = z.object({ - responseType: z.literal('code').optional().default('code'), - grantType: z.literal('authorization_code').optional().default('authorization_code'), +export const oidcConnectorConfigGuard = oauth2ConfigGuard.extend({ + // Override `scope` to ensure it contains 'openid'. scope: z.string().transform(scopePostProcessor), idTokenVerificationConfig: idTokenVerificationConfigGuard, authRequestOptionalConfig: authRequestOptionalConfigGuard.optional(), customConfig: z.record(z.string()).optional(), - ...endpointConfigObject, - ...clientConfigObject, }); -export type OidcConfig = z.infer; +export type OidcConnectorConfig = z.infer; export const authResponseGuard = z .object({ diff --git a/packages/connectors/connector-oidc/src/utils.ts b/packages/connectors/connector-oidc/src/utils.ts index fa227364d0a..e623eb20272 100644 --- a/packages/connectors/connector-oidc/src/utils.ts +++ b/packages/connectors/connector-oidc/src/utils.ts @@ -1,39 +1,12 @@ -import { pick } from '@silverhand/essentials'; -import type { Response } from 'got'; -import { got, HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; - import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; +import { requestTokenEndpoint } from '@logto/connector-oauth'; +import { type KyResponse } from 'ky'; -import { defaultTimeout } from './constant.js'; -import type { AccessTokenResponse, OidcConfig } from './types.js'; +import type { AccessTokenResponse, OidcConnectorConfig } from './types.js'; import { accessTokenResponseGuard, authResponseGuard } from './types.js'; -export const accessTokenRequester = async ( - tokenEndpoint: string, - queryParameters: Record, - timeout: number = defaultTimeout -): Promise => { - try { - const httpResponse = await got.post({ - url: tokenEndpoint, - form: queryParameters, - timeout: { request: timeout }, - }); - - return await accessTokenResponseHandler(httpResponse); - } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); - } - throw error; - } -}; - -const accessTokenResponseHandler = async ( - response: Response -): Promise => { - const result = accessTokenResponseGuard.safeParse(parseJson(response.body)); +const accessTokenResponseHandler = async (response: KyResponse): Promise => { + const result = accessTokenResponseGuard.safeParse(parseJson(await response.text())); if (!result.success) { throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); @@ -42,7 +15,11 @@ const accessTokenResponseHandler = async ( return result.data; }; -export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: string) => { +export const getIdToken = async ( + config: OidcConnectorConfig, + data: unknown, + redirectUri: string +) => { const result = authResponseGuard.safeParse(data); if (!result.success) { @@ -51,14 +28,31 @@ export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: const { code } = result.data; - const { customConfig, ...rest } = config; - - const parameterObject = snakecaseKeys({ - ...pick(rest, 'grantType', 'clientId', 'clientSecret'), - ...customConfig, - code, - redirectUri, + const { + tokenEndpoint, + grantType, + clientId, + clientSecret, + tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + customConfig, + } = config; + + const tokenResponse = await requestTokenEndpoint({ + tokenEndpoint, + tokenEndpointAuthOptions: { + method: tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + }, + tokenRequestBody: { + grantType, + code, + redirectUri, + clientId, + clientSecret, + ...customConfig, + }, }); - return accessTokenRequester(config.tokenEndpoint, parameterObject); + return accessTokenResponseHandler(tokenResponse); }; diff --git a/packages/connectors/connector-saml/package.json b/packages/connectors/connector-saml/package.json index 76dbad1e125..3161db135d4 100644 --- a/packages/connectors/connector-saml/package.json +++ b/packages/connectors/connector-saml/package.json @@ -5,8 +5,12 @@ "author": "Silverhand Inc. ", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", - "fast-xml-parser": "^4.2.5", - "samlify": "2.8.10" + "@silverhand/essentials": "^2.9.0", + "fast-xml-parser": "^4.3.6", + "got": "^14.0.0", + "samlify": "2.8.11", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -48,5 +52,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-sendgrid-email/package.json b/packages/connectors/connector-sendgrid-email/package.json index 650f4b88050..393fe20450e 100644 --- a/packages/connectors/connector-sendgrid-email/package.json +++ b/packages/connectors/connector-sendgrid-email/package.json @@ -4,7 +4,11 @@ "description": "SendGrid Email Service connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-smsaero/package.json b/packages/connectors/connector-smsaero/package.json index da34f59e820..65477b45b71 100644 --- a/packages/connectors/connector-smsaero/package.json +++ b/packages/connectors/connector-smsaero/package.json @@ -4,7 +4,11 @@ "description": "SMSAero connector implementation.", "author": "Danil Tankov ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-smsaero/src/index.ts b/packages/connectors/connector-smsaero/src/index.ts index 95465b05566..3c923c0b3b4 100644 --- a/packages/connectors/connector-smsaero/src/index.ts +++ b/packages/connectors/connector-smsaero/src/index.ts @@ -27,7 +27,6 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction { validateConfig(config, smsAeroConfigGuard); const { email, apiKey, senderName, templates } = config; - const template = templates.find((template) => template.usageType === type); assert( diff --git a/packages/connectors/connector-smtp/package.json b/packages/connectors/connector-smtp/package.json index 7c07a29ee35..a9364a07905 100644 --- a/packages/connectors/connector-smtp/package.json +++ b/packages/connectors/connector-smtp/package.json @@ -5,10 +5,32 @@ "author": "Silverhand Inc. ", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", - "nodemailer": "^6.9.9" + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "nodemailer": "^6.9.9", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "devDependencies": { - "@types/nodemailer": "^6.4.7" + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/nodemailer": "^6.4.7", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" }, "main": "./lib/index.js", "module": "./lib/index.js", diff --git a/packages/connectors/connector-smtp/src/index.ts b/packages/connectors/connector-smtp/src/index.ts index 1517923fda9..8aed0e05dd6 100644 --- a/packages/connectors/connector-smtp/src/index.ts +++ b/packages/connectors/connector-smtp/src/index.ts @@ -70,12 +70,6 @@ const parseContents = (contents: string, contentType: ContextType) => { case ContextType.Html: { return { html: contents }; } - default: { - throw new ConnectorError( - ConnectorErrorCodes.InvalidConfig, - '`contentType` should be ContextType.' - ); - } } }; diff --git a/packages/connectors/connector-tencent-sms/package.json b/packages/connectors/connector-tencent-sms/package.json index 60614fab7d5..02bcb8b9509 100644 --- a/packages/connectors/connector-tencent-sms/package.json +++ b/packages/connectors/connector-tencent-sms/package.json @@ -4,7 +4,11 @@ "description": "Tencent SMS connector implementation.", "author": "StringKe", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-tencent-sms/src/http.ts b/packages/connectors/connector-tencent-sms/src/http.ts index f1868b3dfa0..24375c639ea 100644 --- a/packages/connectors/connector-tencent-sms/src/http.ts +++ b/packages/connectors/connector-tencent-sms/src/http.ts @@ -8,8 +8,8 @@ import type { TencentErrorResponse, TencentSuccessResponse } from './schema.js'; const endpoint = 'sms.tencentcloudapi.com'; function sha256Hmac(message: string, secret: string): string; +// eslint-disable-next-line @typescript-eslint/ban-types function sha256Hmac(message: string, secret: string, encoding: BinaryToTextEncoding): Buffer; - function sha256Hmac(message: string, secret: string, encoding?: BinaryToTextEncoding) { const hmac = crypto.createHmac('sha256', secret); diff --git a/packages/connectors/connector-twilio-sms/package.json b/packages/connectors/connector-twilio-sms/package.json index 91bd74d1796..8a1bcfc6091 100644 --- a/packages/connectors/connector-twilio-sms/package.json +++ b/packages/connectors/connector-twilio-sms/package.json @@ -4,7 +4,11 @@ "description": "Twilio SMS connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-wechat-native/package.json b/packages/connectors/connector-wechat-native/package.json index ffb01ca21db..4a1a319895d 100644 --- a/packages/connectors/connector-wechat-native/package.json +++ b/packages/connectors/connector-wechat-native/package.json @@ -4,7 +4,11 @@ "description": "WeChat native connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-wechat-web/package.json b/packages/connectors/connector-wechat-web/package.json index d14d5c9b49c..50c54bf2af5 100644 --- a/packages/connectors/connector-wechat-web/package.json +++ b/packages/connectors/connector-wechat-web/package.json @@ -4,7 +4,11 @@ "description": "Wechat Web connector implementation.", "author": "Silverhand Inc. ", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/connector-wecom/package.json b/packages/connectors/connector-wecom/package.json index 40ca4af76aa..4510fcc46d6 100644 --- a/packages/connectors/connector-wecom/package.json +++ b/packages/connectors/connector-wecom/package.json @@ -4,7 +4,11 @@ "description": "Wecom connector implementation.", "author": "Dove fork from Wechat Web connector", "dependencies": { - "@logto/connector-kit": "workspace:^3.0.0" + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.0", + "got": "^14.0.0", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" }, "main": "./lib/index.js", "module": "./lib/index.js", @@ -46,5 +50,25 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "publishConfig": { "access": "public" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.10.4", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" } } diff --git a/packages/connectors/templates/package.json b/packages/connectors/templates/package.json index d750610fe60..ede58013e61 100644 --- a/packages/connectors/templates/package.json +++ b/packages/connectors/templates/package.json @@ -21,32 +21,6 @@ "test:ci": "pnpm run test --silent --coverage", "prepublishOnly": "pnpm build" }, - "dependencies": { - "@silverhand/essentials": "^2.9.0", - "got": "^14.0.0", - "snakecase-keys": "^7.0.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^25.0.0", - "@rollup/plugin-json": "^6.0.0", - "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-typescript": "^11.0.0", - "@silverhand/eslint-config": "5.0.0", - "@silverhand/ts-config": "5.0.0", - "@types/node": "^20.9.5", - "@types/supertest": "^6.0.0", - "@vitest/coverage-v8": "^1.4.0", - "eslint": "^8.44.0", - "lint-staged": "^15.0.0", - "nock": "^13.2.2", - "prettier": "^3.0.0", - "rollup": "^4.0.0", - "rollup-plugin-output-size": "^1.3.0", - "supertest": "^6.2.2", - "typescript": "^5.3.3", - "vitest": "^1.4.0" - }, "engines": { "node": "^20.9.0" }, diff --git a/packages/connectors/templates/sync-preset.js b/packages/connectors/templates/sync-preset.js index 65225ca181d..32460417652 100644 --- a/packages/connectors/templates/sync-preset.js +++ b/packages/connectors/templates/sync-preset.js @@ -17,8 +17,10 @@ const templateKeys = Object.keys(templateJson); /** * An object that contains exceptions for scripts that are allowed to be different from the template. + * Value format: `{ "": [""] }` + * Example: `{ "connector-oauth2": ["prepack"] }` */ -const scriptExceptions = {}; +const scriptExceptions = { 'connector-oauth2': ['prepack'] }; const sync = async () => { const packagesDirectory = './'; diff --git a/packages/console/.eslintrc.cjs b/packages/console/.eslintrc.cjs new file mode 100644 index 00000000000..f2cffeab3db --- /dev/null +++ b/packages/console/.eslintrc.cjs @@ -0,0 +1,53 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- a transitive dependency of @silverhand/eslint-config +const xo = require('eslint-config-xo'); + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: '@silverhand/react', + parserOptions: { + project: ['./tsconfig.json', './tsconfig.scripts.gen.json'], + }, + rules: { + 'react/function-component-definition': [ + 'error', + { + namedComponents: 'function-declaration', + unnamedComponents: 'arrow-function', + }, + ], + 'import/no-unused-modules': [ + 'error', + { + unusedExports: true, + }, + ], + }, + overrides: [ + { + files: [ + '*.d.ts', + '**/assets/docs/guides/types.ts', + '**/assets/docs/guides/*/index.ts', + '**/assets/docs/guides/*/components/**/*.tsx', + '**/mdx-components*/*/index.tsx', + ], + rules: { + 'import/no-unused-modules': 'off', + }, + }, + { + files: ['src/pages/**/*.tsx'], + rules: { + 'no-restricted-imports': [ + ...xo.rules['no-restricted-imports'], + { + name: 'react-router-dom', + importNames: ['Route', 'Routes'], + message: + "Don't use `Route` or `Routes` in pages, add routes to `src/hooks/use-console-routes` instead.", + }, + ], + }, + }, + ], +}; diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index 1d632565e22..31946e9f97b 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,31 @@ # Change Log +## 1.14.0 + +### Minor Changes + +- 21bb35b12: refactor the definition of hook event types + + - Add `DataHook` event types. `DataHook` are triggered by data changes. + - Add "interaction" prefix to existing hook event types. Interaction hook events are triggered by end user interactions, e.g. completing sign-in. + +- 5872172cb: enable custom JWT feature for OSS version + + OSS version users can now use custom JWT feature to add custom claims to JWT access tokens payload (previously, this feature was only available to Logto Cloud). + +- 6fe6f87bc: support adding API resource permissions to organization roles and organization permissions in 3rd-party applications + + ## Updates + + - Separated the "Organization template" from the "Organization" page, establishing it as a standalone page for clearer navigation and functionality. + - Enhanced the "Organization template" page by adding functionality that allows users to click on an organization role, which then navigates to the organization role details page where users can view its corresponding permissions and general settings. + - Enabled the assignment of API resource permissions directly from the organization role details page, improving role management and access control. + - Split the permission list for third-party apps into two separate lists: user permissions and organization permissions. Users can now add user profile permissions and API resource permissions for users under user permissions, and add organization permissions and API resource permissions for organizations under organization permissions. + +### Patch Changes + +- 9cf03c8ed: Add Java Spring Boot web integration guide to the application creation page + ## 1.13.0 ### Minor Changes diff --git a/packages/console/jest.config.ts b/packages/console/jest.config.ts index 2b732771d05..f1895b93985 100644 --- a/packages/console/jest.config.ts +++ b/packages/console/jest.config.ts @@ -23,7 +23,6 @@ const config: Config.InitialOptions = { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', - '^@logto/app-insights/(.*)$': '/../app-insights/lib/$1', '^@logto/shared/(.*)$': '/../shared/lib/$1', '\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', }, diff --git a/packages/console/package.json b/packages/console/package.json index d614198b90c..5a9e24c6c50 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@logto/console", - "version": "1.13.0", + "version": "1.14.0", "description": "> TODO: description", "author": "Silverhand Inc. ", "homepage": "https://github.com/logto-io/logto#readme", @@ -27,16 +27,15 @@ "devDependencies": { "@fontsource/roboto-mono": "^5.0.0", "@jest/types": "^29.5.0", - "@logto/app-insights": "workspace:^1.4.0", - "@logto/cloud": "0.2.5-ab8a489", + "@logto/cloud": "0.2.5-e5d8200", "@logto/connector-kit": "workspace:^3.0.0", "@logto/core-kit": "workspace:^2.4.0", "@logto/language-kit": "workspace:^1.1.0", - "@logto/phrases": "workspace:^1.10.0", + "@logto/phrases": "workspace:^1.10.1", "@logto/phrases-experience": "workspace:^1.6.1", - "@logto/react": "^3.0.5", - "@logto/schemas": "workspace:^1.15.0", - "@logto/shared": "workspace:^3.1.0", + "@logto/react": "^3.0.8", + "@logto/schemas": "workspace:^1.16.0", + "@logto/shared": "workspace:^3.1.1", "@mdx-js/react": "^1.6.22", "@monaco-editor/react": "^4.6.0", "@parcel/compressor-brotli": "2.9.3", @@ -45,15 +44,16 @@ "@parcel/transformer-mdx": "2.9.3", "@parcel/transformer-sass": "2.9.3", "@parcel/transformer-svg-react": "2.9.3", - "@silverhand/eslint-config": "5.0.0", - "@silverhand/eslint-config-react": "5.0.0", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/eslint-config-react": "6.0.2", "@silverhand/essentials": "^2.9.0", - "@silverhand/ts-config": "5.0.0", - "@silverhand/ts-config-react": "5.0.0", + "@silverhand/ts-config": "6.0.0", + "@silverhand/ts-config-react": "6.0.0", "@swc/core": "^1.3.52", "@swc/jest": "^0.2.26", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^15.0.0", "@types/color": "^3.0.3", + "@types/debug": "^4.1.7", "@types/jest": "^29.4.0", "@types/mdx": "^2.0.1", "@types/mdx-js__react": "^1.5.5", @@ -63,7 +63,7 @@ "@types/react-helmet": "^6.1.6", "@types/react-modal": "^3.13.1", "@types/react-syntax-highlighter": "^15.5.1", - "@withtyped/client": "^0.8.4", + "@withtyped/client": "^0.8.7", "buffer": "^5.7.1", "classnames": "^2.3.1", "clean-deep": "^3.4.0", @@ -71,10 +71,11 @@ "csstype": "^3.0.11", "date-fns": "^2.29.3", "dayjs": "^1.10.5", + "debug": "^4.3.4", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "dnd-core": "^16.0.0", - "eslint": "^8.44.0", + "eslint": "^8.56.0", "history": "^5.3.0", "i18next": "^22.4.15", "i18next-browser-languagedetector": "^7.0.1", @@ -142,44 +143,6 @@ "@cloud/*": "./src/cloud/$1", "@mdx/components/*": "./src/mdx-components/$1" }, - "eslintConfig": { - "extends": "@silverhand/react", - "parserOptions": { - "project": [ - "./tsconfig.json", - "./tsconfig.scripts.gen.json" - ] - }, - "rules": { - "react/function-component-definition": [ - "error", - { - "namedComponents": "function-declaration", - "unnamedComponents": "arrow-function" - } - ], - "import/no-unused-modules": [ - "error", - { - "unusedExports": true - } - ] - }, - "overrides": [ - { - "files": [ - "*.d.ts", - "**/assets/docs/guides/types.ts", - "**/assets/docs/guides/*/index.ts", - "**/assets/docs/guides/*/components/**/*.tsx", - "**/mdx-components*/*/index.tsx" - ], - "rules": { - "import/no-unused-modules": "off" - } - } - ] - }, "stylelint": { "extends": "@silverhand/eslint-config-react/.stylelintrc" }, diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index 451560e44fb..e211dd52a37 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -1,4 +1,3 @@ -import { AppInsightsBoundary } from '@logto/app-insights/react'; import { UserScope } from '@logto/core-kit'; import { LogtoProvider, Prompt, useLogto } from '@logto/react'; import { @@ -23,7 +22,6 @@ import AppLoading from '@/components/AppLoading'; import { isCloud } from '@/consts/env'; import { cloudApi, getManagementApi, meApi } from '@/consts/resources'; import { ConsoleRoutes } from '@/containers/ConsoleRoutes'; -import useTrackUserId from '@/hooks/use-track-user-id'; import { OnboardingRoutes } from '@/onboarding'; import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data'; @@ -114,26 +112,24 @@ function Providers() { }} > - - - - - {/** - * If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available; - * if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context). - */} - {!isCloud || currentTenantId ? ( - - - - - - ) : ( - - )} - - - + + + + {/** + * If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available; + * if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context). + */} + {!isCloud || currentTenantId ? ( + + + + + + ) : ( + + )} + + ); @@ -146,8 +142,6 @@ function AppRoutes() { const { isOnboarding } = useUserOnboardingData(); const { isAuthenticated } = useLogto(); - useTrackUserId(); - // Authenticated user should load onboarding data before rendering the app. // This looks weird and it will be refactored soon by merging the onboarding // routes with the console routes. diff --git a/packages/console/src/assets/docs/guides/index.ts b/packages/console/src/assets/docs/guides/index.ts index fd12ff800c5..667fb903298 100644 --- a/packages/console/src/assets/docs/guides/index.ts +++ b/packages/console/src/assets/docs/guides/index.ts @@ -15,6 +15,7 @@ import spaAngular from './spa-angular/index'; import spaReact from './spa-react/index'; import spaVanilla from './spa-vanilla/index'; import spaVue from './spa-vue/index'; +import spaWebflow from './spa-webflow/index'; import thirdPartyOidc from './third-party-oidc/index'; import { type Guide } from './types'; import webDotnetCore from './web-dotnet-core/index'; @@ -24,6 +25,7 @@ import webDotnetCoreMvc from './web-dotnet-core-mvc/index'; import webExpress from './web-express/index'; import webGo from './web-go/index'; import webGptPlugin from './web-gpt-plugin/index'; +import webJavaSpringBoot from './web-java-spring-boot/index'; import webNext from './web-next/index'; import webNextAppRouter from './web-next-app-router/index'; import webNextServerActions from './web-next-server-actions/index'; @@ -33,6 +35,7 @@ import webPhp from './web-php/index'; import webPython from './web-python/index'; import webRemix from './web-remix/index'; import webSveltekit from './web-sveltekit/index'; +import webWordpress from './web-wordpress/index'; const guides: Readonly = Object.freeze([ { @@ -105,6 +108,13 @@ const guides: Readonly = Object.freeze([ Component: lazy(async () => import('./web-go/README.mdx')), metadata: webGo, }, + { + order: 1.4, + id: 'web-java-spring-boot', + Logo: lazy(async () => import('./web-java-spring-boot/logo.svg')), + Component: lazy(async () => import('./web-java-spring-boot/README.mdx')), + metadata: webJavaSpringBoot, + }, { order: 1.5, id: 'web-gpt-plugin', @@ -154,6 +164,20 @@ const guides: Readonly = Object.freeze([ Component: lazy(async () => import('./web-php/README.mdx')), metadata: webPhp, }, + { + order: 2.1, + id: 'spa-webflow', + Logo: lazy(async () => import('./spa-webflow/logo.svg')), + Component: lazy(async () => import('./spa-webflow/README.mdx')), + metadata: spaWebflow, + }, + { + order: 2.2, + id: 'web-wordpress', + Logo: lazy(async () => import('./web-wordpress/logo.svg')), + Component: lazy(async () => import('./web-wordpress/README.mdx')), + metadata: webWordpress, + }, { order: 3, id: 'web-python', diff --git a/packages/console/src/assets/docs/guides/m2m-general/index.ts b/packages/console/src/assets/docs/guides/m2m-general/index.ts index d9273f48075..00c1a10c629 100644 --- a/packages/console/src/assets/docs/guides/m2m-general/index.ts +++ b/packages/console/src/assets/docs/guides/m2m-general/index.ts @@ -9,7 +9,7 @@ const metadata: Readonly = Object.freeze({ isFeatured: true, fullGuide: { title: 'Full machine-to-machine integration tutorial', - url: 'https://docs.logto.io/sdk/m2m', + url: 'https://docs.logto.io/quick-starts/m2m', }, }); diff --git a/packages/console/src/assets/docs/guides/native-android/index.ts b/packages/console/src/assets/docs/guides/native-android/index.ts index 17b1d4674b1..de711ff81c1 100644 --- a/packages/console/src/assets/docs/guides/native-android/index.ts +++ b/packages/console/src/assets/docs/guides/native-android/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Android SDK tutorial', - url: 'https://docs.logto.io/sdk/android', + url: 'https://docs.logto.io/quick-starts/android', }, }); diff --git a/packages/console/src/assets/docs/guides/native-expo/index.ts b/packages/console/src/assets/docs/guides/native-expo/index.ts index 0ac4eff3cf1..2ea3ef2620c 100644 --- a/packages/console/src/assets/docs/guides/native-expo/index.ts +++ b/packages/console/src/assets/docs/guides/native-expo/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Expo (React Native) guide', - url: 'https://docs.logto.io/sdk/expo', + url: 'https://docs.logto.io/quick-starts/expo', }, }); diff --git a/packages/console/src/assets/docs/guides/spa-angular/index.ts b/packages/console/src/assets/docs/guides/spa-angular/index.ts index f344cdc6ddd..ef02c1ced7d 100644 --- a/packages/console/src/assets/docs/guides/spa-angular/index.ts +++ b/packages/console/src/assets/docs/guides/spa-angular/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Angular guide', - url: 'https://docs.logto.io/sdk/angular', + url: 'https://docs.logto.io/quick-starts/angular', }, }); diff --git a/packages/console/src/assets/docs/guides/spa-react/index.ts b/packages/console/src/assets/docs/guides/spa-react/index.ts index ca3722548cb..53fdeeff520 100644 --- a/packages/console/src/assets/docs/guides/spa-react/index.ts +++ b/packages/console/src/assets/docs/guides/spa-react/index.ts @@ -13,7 +13,7 @@ const metadata: Readonly = Object.freeze({ isFeatured: true, fullGuide: { title: 'Full React SDK tutorial', - url: 'https://docs.logto.io/sdk/react', + url: 'https://docs.logto.io/quick-starts/react', }, }); diff --git a/packages/console/src/assets/docs/guides/spa-vanilla/index.ts b/packages/console/src/assets/docs/guides/spa-vanilla/index.ts index 2cd99b933d0..48198f3cc2d 100644 --- a/packages/console/src/assets/docs/guides/spa-vanilla/index.ts +++ b/packages/console/src/assets/docs/guides/spa-vanilla/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full vanilla JS SDK tutorial', - url: 'https://docs.logto.io/sdk/vanilla-js', + url: 'https://docs.logto.io/quick-starts/vanilla-js', }, }); diff --git a/packages/console/src/assets/docs/guides/spa-vue/index.ts b/packages/console/src/assets/docs/guides/spa-vue/index.ts index 91ab0f6045c..71f6553bcd8 100644 --- a/packages/console/src/assets/docs/guides/spa-vue/index.ts +++ b/packages/console/src/assets/docs/guides/spa-vue/index.ts @@ -14,7 +14,7 @@ const metadata: Readonly = Object.freeze({ isFeatured: true, fullGuide: { title: 'Full Vue SDK tutorial', - url: 'https://docs.logto.io/sdk/vue', + url: 'https://docs.logto.io/quick-starts/vue', }, }); diff --git a/packages/console/src/assets/docs/guides/spa-webflow/README.mdx b/packages/console/src/assets/docs/guides/spa-webflow/README.mdx new file mode 100644 index 00000000000..1ad4cf7cf04 --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-webflow/README.mdx @@ -0,0 +1,138 @@ +import UriInputField from '@/mdx-components/UriInputField'; +import Tabs from '@mdx/components/Tabs'; +import TabItem from '@mdx/components/TabItem'; +import InlineNotification from '@/ds-components/InlineNotification'; +import Steps from '@/mdx-components/Steps'; +import Step from '@/mdx-components/Step'; + + + + + +### Prerequisits: + +1. Integrating Logto with Webflow requires the "Custom code" feature of Webflow, which requires at least the "Basic" plan. +2. A Webflow site, either use an existing site or create a new one. + + + + + +In this step, we'll add global-level custom code to your Webflow site. Since NPM is not supported in Webflow, we'll use the [jsdelivr.com](https://www.jsdelivr.com/) CDN service to import the Logto SDK. + +Open the "Site settings" page, and navigate to the "Custom code" section. Add the following code to the "Head code" section. + +

+  
+    {``}
+  
+
+ + + + + + + In the following steps, we assume your Webflow site is running on https://your-awesome-site.webflow.io. + + +### Configure Redirect URI + +First, let’s enter your redirect URI. E.g. `https://your-awesome-site.webflow.io/callback`. + + + +### Implement a sign-in button + +Return to your Webflow designer, drag and drop a "Sign in" button to the home page, and assign it an ID “sign-in” for later reference using `getElementById()`. + +
+  
+    {``}
+  
+
+ +### Handle redirect + +

We're almost there! In the last step, we use {`${props.redirectUris[0] ?? 'https://your-awesome-site.webflow.io/callback'}`} as the Redirect URI, and now we need to handle it properly.

+ +First let's create a "Callback" page in Webflow, and simply put some static text "Redirecting..." on it. Then add the following page-level custom code to "Callback" page. + +```html + +``` + +
+ + + +After signing out, it'll be great to redirect user back to your website. Let's add `https://your-awesome-site.webflow.io` as the Post Sign-out URI below, and use it as the parameter when calling `.signOut()`. + + + +### Implement a sign-out button + +Return to the Webflow designer, and add a “Sign out” button on your home page. Similarly, assign an ID “sign-out” to the button, and add the following code to the page-level custom code. + +
+  
+    {`const signOutButton = document.getElementById('sign-out');
+const onClickSignOut = () => logtoClient.signOut('${props.postLogoutRedirectUris[0] ?? 'https://your-awesome-site.webflow.io'}');
+signOutButton.addEventListener('click', onClickSignOut);`}
+  
+
+ +
+ + + +In Logto SDK, generally we can use `logtoClient.isAuthenticated()` method to check the authentication status, if the user is signed in, the value will be `true`; otherwise, it will be `false`. + +In your Webflow site, you can also use it to programmatically show and hide the sign-in and sign-out buttons. Apply the following custom code to adjust button CSS accordingly. + +```js +const isAuthenticated = await logtoClient.isAuthenticated(); + +signInButton.style.display = isAuthenticated ? 'none' : 'block'; +signOutButton.style.display = isAuthenticated ? 'block' : 'none'; +``` + + + + + +Now, test your Webflow site: + +1. Deploy and visit your site URL, the sign-in button should be visible. +2. Click the sign-in button, the SDK will initiate the sign-in process, redirecting you to the Logto sign-in page. +3. After signing in, you will be redirected back to your site, seeing the username and the sign-out button. +4. Click the sign-out button to sign-out. + + + + diff --git a/packages/console/src/assets/docs/guides/spa-webflow/config.json b/packages/console/src/assets/docs/guides/spa-webflow/config.json new file mode 100644 index 00000000000..f00075c683d --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-webflow/config.json @@ -0,0 +1,3 @@ +{ + "order": 2.1 +} diff --git a/packages/console/src/assets/docs/guides/spa-webflow/index.ts b/packages/console/src/assets/docs/guides/spa-webflow/index.ts new file mode 100644 index 00000000000..672802e5e3d --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-webflow/index.ts @@ -0,0 +1,11 @@ +import { ApplicationType } from '@logto/schemas'; + +import { type GuideMetadata } from '../types'; + +const metadata: Readonly = Object.freeze({ + name: 'Webflow', + description: 'Webflow is a SaaS platform for website building and hosting.', + target: ApplicationType.SPA, +}); + +export default metadata; diff --git a/packages/console/src/assets/docs/guides/spa-webflow/logo.svg b/packages/console/src/assets/docs/guides/spa-webflow/logo.svg new file mode 100644 index 00000000000..e5df00dfe65 --- /dev/null +++ b/packages/console/src/assets/docs/guides/spa-webflow/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/README.mdx b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/README.mdx index 0061655307d..1258ea8a865 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/README.mdx +++ b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/README.mdx @@ -184,7 +184,7 @@ var claims = User.Claims; var userId = claims.FirstOrDefault(c => c.Type == LogtoParameters.Claims.Subject)?.Value; ``` -See the [full tutorial](https://docs.logto.io/sdk/dotnet-core/blazor-server/) for more details. +See the [full tutorial](https://docs.logto.io/quick-starts/dotnet-core/blazor-server/) for more details. diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/index.ts b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/index.ts index 70f9efd1d98..9deb50ecb89 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/index.ts +++ b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-server/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full .NET Core (Blazor Server) integration tutorial', - url: 'https://docs.logto.io/sdk/dotnet-core/blazor-server', + url: 'https://docs.logto.io/quick-starts/dotnet-core/blazor-server', }, }); diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/README.mdx b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/README.mdx index d54133189c3..75718996401 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/README.mdx +++ b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/README.mdx @@ -209,7 +209,7 @@ Now you can run the web application and try to sign in and sign out with Logto: To get the user profile, you can use the `User?.Profile` property; to fetch the access token, you can use the `User?.AccessToken` property or add it to your HTTP client using `.AddAccessToken()`. -See the [full tutorial](https://docs.logto.io/sdk/dotnet-core/blazor-wasm/) for more details. +See the [full tutorial](https://docs.logto.io/quick-starts/dotnet-core/blazor-wasm/) for more details. diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/index.ts b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/index.ts index 553eb1d8c79..55770849b8c 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/index.ts +++ b/packages/console/src/assets/docs/guides/web-dotnet-core-blazor-wasm/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full .NET Core (Blazor WASM) integration tutorial', - url: 'https://docs.logto.io/sdk/dotnet-core/blazor-wasm', + url: 'https://docs.logto.io/quick-starts/dotnet-core/blazor-wasm', }, }); diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/README.mdx b/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/README.mdx index 7dead35f25e..8fa99eea24c 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/README.mdx +++ b/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/README.mdx @@ -173,7 +173,7 @@ var claims = User.Claims; var userId = claims.FirstOrDefault(c => c.Type == LogtoParameters.Claims.Subject)?.Value; ``` -See the [full tutorial](https://docs.logto.io/sdk/dotnet-core/mvc/) for more details. +See the [full tutorial](https://docs.logto.io/quick-starts/dotnet-core/mvc/) for more details. diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/index.ts b/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/index.ts index e65708141df..315a8943e86 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/index.ts +++ b/packages/console/src/assets/docs/guides/web-dotnet-core-mvc/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full .NET Core (MVC) integration tutorial', - url: 'https://docs.logto.io/sdk/dotnet-core/mvc', + url: 'https://docs.logto.io/quick-starts/dotnet-core/mvc', }, }); diff --git a/packages/console/src/assets/docs/guides/web-dotnet-core/README.mdx b/packages/console/src/assets/docs/guides/web-dotnet-core/README.mdx index bff61cdfc48..245a9fe3a51 100644 --- a/packages/console/src/assets/docs/guides/web-dotnet-core/README.mdx +++ b/packages/console/src/assets/docs/guides/web-dotnet-core/README.mdx @@ -183,7 +183,7 @@ var claims = User.Claims; var userId = claims.FirstOrDefault(c => c.Type == LogtoParameters.Claims.Subject)?.Value; ``` -See the [full tutorial](https://docs.logto.io/sdk/dotnet-core/razor/) for more details. +See the [full tutorial](https://docs.logto.io/quick-starts/dotnet-core/razor/) for more details. diff --git a/packages/console/src/assets/docs/guides/web-express/index.ts b/packages/console/src/assets/docs/guides/web-express/index.ts index e84b390c5ec..6c22e22b2e8 100644 --- a/packages/console/src/assets/docs/guides/web-express/index.ts +++ b/packages/console/src/assets/docs/guides/web-express/index.ts @@ -13,7 +13,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Express SDK tutorial', - url: 'https://docs.logto.io/sdk/express', + url: 'https://docs.logto.io/quick-starts/express', }, }); diff --git a/packages/console/src/assets/docs/guides/web-go/index.ts b/packages/console/src/assets/docs/guides/web-go/index.ts index 811fca43544..6bd10eae34c 100644 --- a/packages/console/src/assets/docs/guides/web-go/index.ts +++ b/packages/console/src/assets/docs/guides/web-go/index.ts @@ -13,7 +13,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Go SDK tutorial', - url: 'https://docs.logto.io/sdk/go', + url: 'https://docs.logto.io/quick-starts/go', }, }); diff --git a/packages/console/src/assets/docs/guides/web-java-spring-boot/README.mdx b/packages/console/src/assets/docs/guides/web-java-spring-boot/README.mdx new file mode 100644 index 00000000000..7019654e233 --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-java-spring-boot/README.mdx @@ -0,0 +1,330 @@ +import UriInputField from '@/mdx-components/UriInputField'; +import Steps from '@/mdx-components/Steps'; +import Step from '@/mdx-components/Step'; + + + + + This tutorial will show you how to integrate Logto into your Java Spring Boot web application. + +
    +
  • + The sample was created using the Spring Boot [securing web + starter](https://spring.io/guides/gs/securing-web). Following the instructions to bootstrap a + new web application. +
  • +
  • + The sample uses the [Spring Security + OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2) library to handle OIDC + authentication and integrate with Logto. +
  • +
+ +Before we begin, make sure you have went through the spring boot guides linked above. + +
+ + + Include the following dependencies in your `build.gradle` file: + +```gradle +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' +} +``` + +The sample uses [gradle](https://spring.io/guides/gs/gradle) as the build tool. You can use +maven or any other build tool as well. The configurations might be slightly different. + +For maven, include the following dependencies in your `pom.xml` file: + +```maven + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-client + +``` + + + + + +Register your application with Logto to get the client credentials and IdP configurations. +Add the following configuration to your `application.properties` file: + +
+  
+    {`spring.security.oauth2.client.registration.logto.client-name=logto
+spring.security.oauth2.client.registration.logto.client-id=${props.app.id}
+spring.security.oauth2.client.registration.logto.client-secret=${props.app.secret}
+spring.security.oauth2.client.registration.logto.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
+spring.security.oauth2.client.registration.logto.authorization-grant-type=authorization_code
+spring.security.oauth2.client.registration.logto.scope=openid,profile,email,offline_access
+spring.security.oauth2.client.registration.logto.provider=logto
+
+spring.security.oauth2.client.provider.logto.issuer-uri=${props.endpoint}oidc
+spring.security.oauth2.client.provider.logto.authorization-uri=${props.endpoint}oidc/auth
+spring.security.oauth2.client.provider.logto.jwk-set-uri=${props.endpoint}oidc/jwks
+  `}
+  
+
+ +
+ + + +In order to redirect users back to your application after they sign in, you need to set the redirect URI using the `client.registration.logto.redirect-uri` property in the previous step. + + + +e.g. In our example, the redirect URI is `http://localhost:8080/login/oauth2/code/logto`. + + + + + +#### Create a new class `WebSecurityConfig` in your project: + +```java +package com.example.securingweb; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +@Configuration +@EnableWebSecurity + +public class WebSecurityConfig { + // ... +} +``` + +#### Create a idTokenDecoderFactory bean to set the JWS algorithm to `ES384`: + +This is required because Logto uses ES384 as the default algorithm, we need to update the OidcIdTokenDecoderFactory to use the same algorithm. + +```java +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; + +public class WebSecurityConfig { + // ... + + @Bean + public JwtDecoderFactory idTokenDecoderFactory() { + OidcIdTokenDecoderFactory idTokenDecoderFactory = new OidcIdTokenDecoderFactory(); + idTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> SignatureAlgorithm.ES384); + return idTokenDecoderFactory; + } +} +``` + +#### Create a LoginSuccessHandler class to handle the login success event: + +Redirect the user to the user page after successful login: + +```java +package com.example.securingweb; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class CustomSuccessHandler implements AuthenticationSuccessHandler { + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + response.sendRedirect("/user"); + } +} +``` + +#### Create a LogoutSuccessHandler class to handle the logout success event: + +Clear the session and redirect the user to the home page. + +```java +package com.example.securingweb; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +public class CustomLogoutHandler implements LogoutSuccessHandler { + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + HttpSession session = request.getSession(); + + if (session != null) { + session.invalidate(); + } + + response.sendRedirect("/home"); + } +} +``` + +#### Create a `securityFilterChain` bean to configure the security configuration: + +Add the following code to complete the `WebSecurityConfig` class: + +```java +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; + +public class WebSecurityConfig { + // ... + + @Bean + public DefaultSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeRequests(authorizeRequests -> + authorizeRequests + .antMatchers("/", "/home").permitAll() // Allow access to the home page + .anyRequest().authenticated() // All other requests require authentication + ) + .oauth2Login(oauth2Login -> + oauth2Login + .successHandler(new CustomSuccessHandler()) + ) + .logout(logout -> + logout + .logoutSuccessHandler(new CustomLogoutHandler()) + ); + return http.build(); + } +} +``` + + + + + +(You may skip this step if you already have a home page in your project) + +HomeController.java: + +```java +package com.example.securingweb; + +import java.security.Principal; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + @GetMapping({ "/", "/home" }) + public String home(Principal principal) { + return principal != null ? "redirect:/user" : "home"; + } +} +``` + +This controller will redirect the user to the user page if the user is authenticated, otherwise, it will show the home page. + +home.html: + +```html + +

Welcome!

+ +

Login with Logto

+ +``` + +
+ + + +Create a new controller to handle the user page: + +```java +package com.example.securingweb; + +import java.security.Principal; +import java.util.Map; + +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/user") +public class UserController { + + @GetMapping + public String user(Model model, Principal principal) { + if (principal instanceof OAuth2AuthenticationToken) { + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) principal; + OAuth2User oauth2User = token.getPrincipal(); + Map attributes = oauth2User.getAttributes(); + + model.addAttribute("username", attributes.get("username")); + model.addAttribute("email", attributes.get("email")); + model.addAttribute("sub", attributes.get("sub")); + } + + return "user"; + } +} +``` + +Read the user information from the `OAuth2User` object and pass it to the `user.html` template. + +user.html: + +```html + +

User Details

+
+

+

name:
+
email:
+
id:
+

+
+ +
+ +
+ +``` + +
+ +
diff --git a/packages/console/src/assets/docs/guides/web-java/config.json b/packages/console/src/assets/docs/guides/web-java-spring-boot/config.json similarity index 100% rename from packages/console/src/assets/docs/guides/web-java/config.json rename to packages/console/src/assets/docs/guides/web-java-spring-boot/config.json diff --git a/packages/console/src/assets/docs/guides/web-java-spring-boot/index.ts b/packages/console/src/assets/docs/guides/web-java-spring-boot/index.ts new file mode 100644 index 00000000000..cf1e468a2cb --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-java-spring-boot/index.ts @@ -0,0 +1,16 @@ +import { ApplicationType } from '@logto/schemas'; + +import { type GuideMetadata } from '../types'; + +const metadata: Readonly = Object.freeze({ + name: 'Java Spring Boot Web', + description: + 'Spring Boot is a web framework for Java that enables developers to build secure, fast, and scalable server applications with the Java programming language.', + target: ApplicationType.Traditional, + sample: { + repo: 'spring-boot-sample', + path: '', + }, +}); + +export default metadata; diff --git a/packages/console/src/assets/docs/guides/web-java-spring-boot/logo.svg b/packages/console/src/assets/docs/guides/web-java-spring-boot/logo.svg new file mode 100644 index 00000000000..d7256ddcf41 --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-java-spring-boot/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/console/src/assets/docs/guides/web-java/index.ts b/packages/console/src/assets/docs/guides/web-java/index.ts deleted file mode 100644 index 61b56a2be49..00000000000 --- a/packages/console/src/assets/docs/guides/web-java/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApplicationType } from '@logto/schemas'; - -import { type GuideMetadata } from '../types'; - -const metadata: Readonly = Object.freeze({ - name: 'Java Web', - description: - 'Java Web is a web framework for Java that enables developers to build secure, fast, and scalable server applications with the Java programming language.', - target: ApplicationType.Traditional, -}); - -export default metadata; diff --git a/packages/console/src/assets/docs/guides/web-java/logo.svg b/packages/console/src/assets/docs/guides/web-java/logo.svg deleted file mode 100644 index cb4151622a5..00000000000 --- a/packages/console/src/assets/docs/guides/web-java/logo.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/packages/console/src/assets/docs/guides/web-next-app-router/index.ts b/packages/console/src/assets/docs/guides/web-next-app-router/index.ts index c4132c9b49d..692ecdf0f66 100644 --- a/packages/console/src/assets/docs/guides/web-next-app-router/index.ts +++ b/packages/console/src/assets/docs/guides/web-next-app-router/index.ts @@ -13,7 +13,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Next.js App Router SDK tutorial', - url: 'https://docs.logto.io/sdk/next-app-router', + url: 'https://docs.logto.io/quick-starts/next-app-router', }, }); diff --git a/packages/console/src/assets/docs/guides/web-next/index.ts b/packages/console/src/assets/docs/guides/web-next/index.ts index 9691318a529..f8d5debac28 100644 --- a/packages/console/src/assets/docs/guides/web-next/index.ts +++ b/packages/console/src/assets/docs/guides/web-next/index.ts @@ -14,7 +14,7 @@ const metadata: Readonly = Object.freeze({ isFeatured: true, fullGuide: { title: 'Full Next.js SDK tutorial', - url: 'https://docs.logto.io/sdk/next', + url: 'https://docs.logto.io/quick-starts/next', }, }); diff --git a/packages/console/src/assets/docs/guides/web-nuxt/index.ts b/packages/console/src/assets/docs/guides/web-nuxt/index.ts index 9185cbf7486..00953f2e68a 100644 --- a/packages/console/src/assets/docs/guides/web-nuxt/index.ts +++ b/packages/console/src/assets/docs/guides/web-nuxt/index.ts @@ -13,7 +13,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Nuxt guide', - url: 'https://docs.logto.io/sdk/nuxt', + url: 'https://docs.logto.io/quick-starts/nuxt', }, }); diff --git a/packages/console/src/assets/docs/guides/web-php/README.mdx b/packages/console/src/assets/docs/guides/web-php/README.mdx index 265c05ef0ba..08ad782494e 100644 --- a/packages/console/src/assets/docs/guides/web-php/README.mdx +++ b/packages/console/src/assets/docs/guides/web-php/README.mdx @@ -159,7 +159,7 @@ Note that a field (claim) with `null` value doesn't mean the field is set. The r For example, if we didn't request the `email` scope when signing in, and the `email` field will be `null`. However, if we requested the `email` scope, the `email` field will be the user's email address if available. -To learn more about scopes and claims, see [Get user information](https://docs.logto.io/sdk/php/#get-user-information). +To learn more about scopes and claims, see [Get user information](https://docs.logto.io/quick-starts/php/#get-user-information). diff --git a/packages/console/src/assets/docs/guides/web-php/index.ts b/packages/console/src/assets/docs/guides/web-php/index.ts index bbb7930bdbb..8d5cec8efa3 100644 --- a/packages/console/src/assets/docs/guides/web-php/index.ts +++ b/packages/console/src/assets/docs/guides/web-php/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full PHP SDK tutorial', - url: 'https://docs.logto.io/sdk/php', + url: 'https://docs.logto.io/quick-starts/php', }, }); diff --git a/packages/console/src/assets/docs/guides/web-python/README.mdx b/packages/console/src/assets/docs/guides/web-python/README.mdx index 5f6ff9181e4..c29d61fafa4 100644 --- a/packages/console/src/assets/docs/guides/web-python/README.mdx +++ b/packages/console/src/assets/docs/guides/web-python/README.mdx @@ -177,7 +177,7 @@ Adding `exclude_unset=True` will exclude unset fields from the JSON output, whic For example, if we didn't request the `email` scope when signing in, and the `email` field will be excluded from the JSON output. However, if we requested the `email` scope, but the user doesn't have an email address, the `email` field will be included in the JSON output with a `null` value. -To learn more about scopes and claims, see [Get user information](https://docs.logto.io/sdk/python/#get-user-information). +To learn more about scopes and claims, see [Get user information](https://docs.logto.io/quick-starts/python/#get-user-information). diff --git a/packages/console/src/assets/docs/guides/web-python/index.ts b/packages/console/src/assets/docs/guides/web-python/index.ts index 60c5c918c35..8840be3d412 100644 --- a/packages/console/src/assets/docs/guides/web-python/index.ts +++ b/packages/console/src/assets/docs/guides/web-python/index.ts @@ -12,7 +12,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full Python SDK tutorial', - url: 'https://docs.logto.io/sdk/python', + url: 'https://docs.logto.io/quick-starts/python', }, }); diff --git a/packages/console/src/assets/docs/guides/web-sveltekit/index.ts b/packages/console/src/assets/docs/guides/web-sveltekit/index.ts index 9f945df2694..7b429bbbf0a 100644 --- a/packages/console/src/assets/docs/guides/web-sveltekit/index.ts +++ b/packages/console/src/assets/docs/guides/web-sveltekit/index.ts @@ -13,7 +13,7 @@ const metadata: Readonly = Object.freeze({ }, fullGuide: { title: 'Full SvelteKit guide', - url: 'https://docs.logto.io/sdk/sveltekit', + url: 'https://docs.logto.io/quick-starts/sveltekit', }, }); diff --git a/packages/console/src/assets/docs/guides/web-wordpress/README.mdx b/packages/console/src/assets/docs/guides/web-wordpress/README.mdx new file mode 100644 index 00000000000..192947c2553 --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-wordpress/README.mdx @@ -0,0 +1,69 @@ +import UriInputField from '@/mdx-components/UriInputField'; +import Tabs from '@mdx/components/Tabs'; +import TabItem from '@mdx/components/TabItem'; +import InlineNotification from '@/ds-components/InlineNotification'; +import Steps from '@/mdx-components/Steps'; +import Step from '@/mdx-components/Step'; + + + + + +This tutorial will show you how to integrate Logto into your [Wordpress](https://wordpress.org) website. + +Follow the official [Wordpress installation guide](https://wordpress.org/support/article/how-to-install-wordpress/) to set up your Wordpress website before proceeding. + + + + + +We will use the [OpenID Connect Generic](https://wordpress.org/plugins/generic-openid-connect/) plugin to integrate Logto via OIDC protocal into your Wordpress website. + +1. Log in to your WordPress site. +2. Navigate to "Plugins" and click "Add New". +3. Search for "OpenID Connect Generic" and install the plugin by daggerhart. +4. Activate the plugin. + + + + + +First, let’s enter your redirect URI. You can find it in the plugin settings, scroll down to the "Notes" section, and copy the "Redirect URI" value. + + + +Don't forget to click the **Save** button. + + + + + +Refer to the following table for the necessary configuration details: + +| Plugin Field | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Client ID | The app ID of your Logto application | +| Client Secret | The app secret of your Logto application | +| OpenID Scope | Enter `email profile openid offline_access` | +| Login Endpoint URL | The authorization endpoint URL of your Logto application, which is https://[tenant-id].logto.app/oidc/auth, you can click "show endpoint details" in the Logto application page to get the URL. | +| Userinfo Endpoint URL | The userinfo endpoint URL of your Logto application, which is https://[tenant-id].logto.app/oidc/me. | +| Token Validation Endpoint URL | The token validation endpoint URL of your Logto application, which is https://[tenant-id].logto.app/oidc/token. | +| End Session Endpoint URL | The end session endpoint URL of your Logto application, which is https://[tenant-id].logto.app/oidc/session/end. | +| Identity Key | The unique key in the ID token that contains the user's identity, it can be email or sub, depending on your configuration. | +| Nickname Key | The key in the ID token that contains the user's nickname, you can set it to sub and change it later. | + + + + + +Now, you can test your application: + +1. Log out of your WordPress site. +2. Visit the WordPress login page and click the "Sign in with Logto" button. +3. You will be redirected to the Logto sign-in page. +4. Sign in with your Logto account. +5. You will be redirected back to the WordPress site and logged in automatically. + + + + diff --git a/packages/console/src/assets/docs/guides/web-wordpress/config.json b/packages/console/src/assets/docs/guides/web-wordpress/config.json new file mode 100644 index 00000000000..4fa4d6a2ae9 --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-wordpress/config.json @@ -0,0 +1,3 @@ +{ + "order": 2.2 +} diff --git a/packages/console/src/assets/docs/guides/web-wordpress/index.ts b/packages/console/src/assets/docs/guides/web-wordpress/index.ts new file mode 100644 index 00000000000..53ca03b1780 --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-wordpress/index.ts @@ -0,0 +1,15 @@ +import { ApplicationType } from '@logto/schemas'; + +import { type GuideMetadata } from '../types'; + +const metadata: Readonly = Object.freeze({ + name: 'WordPress', + description: 'Integrate Logto into your WordPress app.', + target: ApplicationType.Traditional, + fullGuide: { + title: 'Authorization and role mapping in WordPress', + url: 'https://blog.logto.io/integrate-with-wordpress-authorization/', + }, +}); + +export default metadata; diff --git a/packages/console/src/assets/docs/guides/web-wordpress/logo.svg b/packages/console/src/assets/docs/guides/web-wordpress/logo.svg new file mode 100644 index 00000000000..72183e90424 --- /dev/null +++ b/packages/console/src/assets/docs/guides/web-wordpress/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/console/src/assets/icons/key.svg b/packages/console/src/assets/icons/key.svg index 821b2ce867f..a88f3729e26 100644 --- a/packages/console/src/assets/icons/key.svg +++ b/packages/console/src/assets/icons/key.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/console/src/assets/icons/organization-template-feature.svg b/packages/console/src/assets/icons/organization-template-feature.svg new file mode 100644 index 00000000000..6df2cd2cc30 --- /dev/null +++ b/packages/console/src/assets/icons/organization-template-feature.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/cloud/AppRoutes.tsx b/packages/console/src/cloud/AppRoutes.tsx index 3230e90cc91..b3bb8750f6d 100644 --- a/packages/console/src/cloud/AppRoutes.tsx +++ b/packages/console/src/cloud/AppRoutes.tsx @@ -6,6 +6,8 @@ import { GlobalAnonymousRoute, GlobalRoute } from '@/contexts/TenantsProvider'; import AcceptInvitation from '@/pages/AcceptInvitation'; import Callback from '@/pages/Callback'; import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback'; +import Profile from '@/pages/Profile'; +import HandleSocialCallback from '@/pages/Profile/containers/HandleSocialCallback'; import * as styles from './AppRoutes.module.scss'; import Main from './pages/Main'; @@ -19,12 +21,12 @@ function AppRoutes() { } /> } /> }> - {isCloud && ( - } - /> - )} + } + /> + } /> + } /> } /> } /> diff --git a/packages/console/src/cloud/pages/Main/InvitationList/index.tsx b/packages/console/src/cloud/pages/Main/InvitationList/index.tsx index db7bd5317dc..e86b71147fe 100644 --- a/packages/console/src/cloud/pages/Main/InvitationList/index.tsx +++ b/packages/console/src/cloud/pages/Main/InvitationList/index.tsx @@ -16,7 +16,7 @@ import useUserOnboardingData from '@/onboarding/hooks/use-user-onboarding-data'; import * as styles from './index.module.scss'; type Props = { - invitations: InvitationListResponse; + readonly invitations: InvitationListResponse; }; function InvitationList({ invitations }: Props) { diff --git a/packages/console/src/cloud/pages/Main/Redirect.tsx b/packages/console/src/cloud/pages/Main/Redirect.tsx index 45ed97bf4c9..081e299a2d7 100644 --- a/packages/console/src/cloud/pages/Main/Redirect.tsx +++ b/packages/console/src/cloud/pages/Main/Redirect.tsx @@ -3,7 +3,7 @@ import { useContext, useEffect } from 'react'; import AppLoading from '@/components/AppLoading'; import { TenantsContext } from '@/contexts/TenantsProvider'; -function Redirect({ toTenantId }: { toTenantId: string }) { +function Redirect({ toTenantId }: { readonly toTenantId: string }) { const { navigateTenant } = useContext(TenantsContext); useEffect(() => { diff --git a/packages/console/src/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/index.tsx b/packages/console/src/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/index.tsx index 40cb667c97b..0c7f65c9707 100644 --- a/packages/console/src/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/index.tsx +++ b/packages/console/src/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/index.tsx @@ -15,7 +15,7 @@ import useTheme from '@/hooks/use-theme'; import * as styles from './index.module.scss'; type Props = { - className?: string; + readonly className?: string; }; function TenantLandingPageContent({ className }: Props) { diff --git a/packages/console/src/components/ActionBar/index.tsx b/packages/console/src/components/ActionBar/index.tsx index a31705dab7f..11d09537973 100644 --- a/packages/console/src/components/ActionBar/index.tsx +++ b/packages/console/src/components/ActionBar/index.tsx @@ -5,9 +5,9 @@ import ProgressBar from '../ProgressBar'; import * as styles from './index.module.scss'; type Props = { - step: number; - totalSteps: number; - children: ReactNode; + readonly step: number; + readonly totalSteps: number; + readonly children: ReactNode; }; function ActionBar({ step, totalSteps, children }: Props) { diff --git a/packages/console/src/components/ActionsButton/index.tsx b/packages/console/src/components/ActionsButton/index.tsx index 41d6bac0e8b..575b05f1bd6 100644 --- a/packages/console/src/components/ActionsButton/index.tsx +++ b/packages/console/src/components/ActionsButton/index.tsx @@ -16,18 +16,18 @@ type Props = { /** A function that will be called when the user confirms the deletion. If not provided, * the delete button will not be displayed. */ - onDelete?: () => void | Promise; + readonly onDelete?: () => void | Promise; /** * A function that will be called when the user clicks the edit button. If not provided, * the edit button will not be displayed. */ - onEdit?: () => void | Promise; + readonly onEdit?: () => void | Promise; /** The translation key of the content that will be displayed in the confirmation modal. */ - deleteConfirmation: AdminConsoleKey; + readonly deleteConfirmation: AdminConsoleKey; /** The name of the field that is being operated. */ - fieldName: AdminConsoleKey; + readonly fieldName: AdminConsoleKey; /** Overrides the default translations of the edit and delete buttons. */ - textOverrides?: { + readonly textOverrides?: { /** The translation key of the edit button. */ edit?: AdminConsoleKey; /** The translation key of the delete button. */ diff --git a/packages/console/src/components/AppError/index.tsx b/packages/console/src/components/AppError/index.tsx index 179b4d2a04e..1237c738f3a 100644 --- a/packages/console/src/components/AppError/index.tsx +++ b/packages/console/src/components/AppError/index.tsx @@ -12,11 +12,11 @@ import { onKeyDownHandler } from '@/utils/a11y'; import * as styles from './index.module.scss'; type Props = { - title?: string; - errorCode?: string; - errorMessage?: string; - callStack?: string; - children?: React.ReactNode; + readonly title?: string; + readonly errorCode?: string; + readonly errorMessage?: string; + readonly callStack?: string; + readonly children?: React.ReactNode; }; function AppError({ title, errorCode, errorMessage, callStack, children }: Props) { diff --git a/packages/console/src/components/ApplicationIcon/index.tsx b/packages/console/src/components/ApplicationIcon/index.tsx index 108445c8e00..e4c1d7bf1f7 100644 --- a/packages/console/src/components/ApplicationIcon/index.tsx +++ b/packages/console/src/components/ApplicationIcon/index.tsx @@ -10,9 +10,9 @@ import { import useTheme from '@/hooks/use-theme'; type Props = { - type: ApplicationType; - className?: string; - isThirdParty?: boolean; + readonly type: ApplicationType; + readonly className?: string; + readonly isThirdParty?: boolean; }; const getIcon = (type: ApplicationType, isLightMode: boolean, isThirdParty?: boolean) => { diff --git a/packages/console/src/components/ApplicationName/index.tsx b/packages/console/src/components/ApplicationName/index.tsx index 03c5c46100d..2e47e464f39 100644 --- a/packages/console/src/components/ApplicationName/index.tsx +++ b/packages/console/src/components/ApplicationName/index.tsx @@ -13,8 +13,8 @@ import { shouldRetryOnError } from '@/utils/request'; import * as styles from './index.module.scss'; type Props = { - applicationId: string; - isLink?: boolean; + readonly applicationId: string; + readonly isLink?: boolean; }; function ApplicationName({ applicationId, isLink = false }: Props) { diff --git a/packages/console/src/components/AuditLogTable/components/ApplicationSelector/index.tsx b/packages/console/src/components/AuditLogTable/components/ApplicationSelector/index.tsx index d8dcdfb5bd2..3c717f9b90b 100644 --- a/packages/console/src/components/AuditLogTable/components/ApplicationSelector/index.tsx +++ b/packages/console/src/components/AuditLogTable/components/ApplicationSelector/index.tsx @@ -10,8 +10,8 @@ import { TenantsContext } from '@/contexts/TenantsProvider'; import Select from '@/ds-components/Select'; type Props = { - value?: string; - onChange: (value?: string) => void; + readonly value?: string; + readonly onChange: (value?: string) => void; }; function ApplicationSelector({ value, onChange }: Props) { diff --git a/packages/console/src/components/AuditLogTable/components/EventName/index.tsx b/packages/console/src/components/AuditLogTable/components/EventName/index.tsx index 1c0a75f489c..95058d38641 100644 --- a/packages/console/src/components/AuditLogTable/components/EventName/index.tsx +++ b/packages/console/src/components/AuditLogTable/components/EventName/index.tsx @@ -9,9 +9,9 @@ import useTenantPathname from '@/hooks/use-tenant-pathname'; import * as styles from './index.module.scss'; type Props = { - eventKey: string; - isSuccess: boolean; - to?: string; + readonly eventKey: string; + readonly isSuccess: boolean; + readonly to?: string; }; function EventName({ eventKey, isSuccess, to }: Props) { diff --git a/packages/console/src/components/AuditLogTable/components/EventSelector/index.tsx b/packages/console/src/components/AuditLogTable/components/EventSelector/index.tsx index 52e23d983b8..d405a05b8ac 100644 --- a/packages/console/src/components/AuditLogTable/components/EventSelector/index.tsx +++ b/packages/console/src/components/AuditLogTable/components/EventSelector/index.tsx @@ -4,9 +4,9 @@ import { logEventTitle } from '@/consts/logs'; import Select, { type Option } from '@/ds-components/Select'; type Props = { - value?: string; - onChange: (value?: string) => void; - options?: Array>; + readonly value?: string; + readonly onChange: (value?: string) => void; + readonly options?: Array>; }; const defaultEventOptions = Object.entries(logEventTitle).map(([value, title]) => ({ diff --git a/packages/console/src/components/AuditLogTable/index.tsx b/packages/console/src/components/AuditLogTable/index.tsx index e70e23f93a2..6901f5fa3a6 100644 --- a/packages/console/src/components/AuditLogTable/index.tsx +++ b/packages/console/src/components/AuditLogTable/index.tsx @@ -27,9 +27,9 @@ const auditLogEventOptions = Object.entries(auditLogEventTitle).map(([value, tit })); type Props = { - applicationId?: string; - userId?: string; - className?: string; + readonly applicationId?: string; + readonly userId?: string; + readonly className?: string; }; function AuditLogTable({ applicationId, userId, className }: Props) { diff --git a/packages/console/src/components/BasicWebhookForm/index.tsx b/packages/console/src/components/BasicWebhookForm/index.tsx index 8e28e5ed8b8..b332732964b 100644 --- a/packages/console/src/components/BasicWebhookForm/index.tsx +++ b/packages/console/src/components/BasicWebhookForm/index.tsx @@ -1,4 +1,4 @@ -import { HookEvent, type Hook, type HookConfig } from '@logto/schemas'; +import { type HookEvent, type Hook, type HookConfig, InteractionHookEvent } from '@logto/schemas'; import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -10,7 +10,8 @@ import { uriValidator } from '@/utils/validator'; import * as styles from './index.module.scss'; -const hookEventOptions = Object.values(HookEvent).map((event) => ({ +// TODO: Implement all hook events +const hookEventOptions = Object.values(InteractionHookEvent).map((event) => ({ title: hookEventLabel[event], value: event, })); diff --git a/packages/console/src/components/BillInfo/index.tsx b/packages/console/src/components/BillInfo/index.tsx index 297a7954381..c2a3a25f2df 100644 --- a/packages/console/src/components/BillInfo/index.tsx +++ b/packages/console/src/components/BillInfo/index.tsx @@ -14,8 +14,8 @@ import useSubscribe from '@/hooks/use-subscribe'; import * as styles from './index.module.scss'; type Props = { - cost: number; - isManagePaymentVisible?: boolean; + readonly cost: number; + readonly isManagePaymentVisible?: boolean; }; function BillInfo({ cost, isManagePaymentVisible }: Props) { diff --git a/packages/console/src/components/Breakable/index.tsx b/packages/console/src/components/Breakable/index.tsx index 1bdbd573355..ca5bf216797 100644 --- a/packages/console/src/components/Breakable/index.tsx +++ b/packages/console/src/components/Breakable/index.tsx @@ -1,7 +1,7 @@ import * as styles from './index.module.scss'; type Props = { - children: React.ReactNode; + readonly children: React.ReactNode; }; /** diff --git a/packages/console/src/components/ChargeNotification/index.tsx b/packages/console/src/components/ChargeNotification/index.tsx index 22f61e4d6d5..2fd62a5f4df 100644 --- a/packages/console/src/components/ChargeNotification/index.tsx +++ b/packages/console/src/components/ChargeNotification/index.tsx @@ -11,16 +11,16 @@ import TextLink from '@/ds-components/TextLink'; import useConfigs from '@/hooks/use-configs'; type Props = { - hasSurpassedLimit: boolean; - quotaItemPhraseKey: TFuncKey<'translation', 'admin_console.upsell.add_on_quota_item'>; - quotaLimit?: number; - className?: string; + readonly hasSurpassedLimit: boolean; + readonly quotaItemPhraseKey: TFuncKey<'translation', 'admin_console.upsell.add_on_quota_item'>; + readonly quotaLimit?: number; + readonly className?: string; /** * The key of the flag in `checkedChargeNotification` config from the AdminConsoleData. * Used to determine whether the notification has been checked. * @see{@link AdminConsoleData} */ - checkedFlagKey: keyof Truthy; + readonly checkedFlagKey: keyof Truthy; }; /** diff --git a/packages/console/src/components/ConnectorForm/BasicForm/index.tsx b/packages/console/src/components/ConnectorForm/BasicForm/index.tsx index 9f66bfe961c..2a72ed192cb 100644 --- a/packages/console/src/components/ConnectorForm/BasicForm/index.tsx +++ b/packages/console/src/components/ConnectorForm/BasicForm/index.tsx @@ -19,10 +19,10 @@ import { uriValidator } from '@/utils/validator'; import * as styles from './index.module.scss'; type Props = { - isAllowEditTarget?: boolean; - isDarkDefaultVisible?: boolean; - isStandard?: boolean; - conflictConnectorName?: Record; + readonly isAllowEditTarget?: boolean; + readonly isDarkDefaultVisible?: boolean; + readonly isStandard?: boolean; + readonly conflictConnectorName?: Record; }; function BasicForm({ diff --git a/packages/console/src/components/ConnectorForm/ConfigForm/ConfigFormFields/index.tsx b/packages/console/src/components/ConnectorForm/ConfigForm/ConfigFormFields/index.tsx index 318e2549b7a..e8ae334fb1d 100644 --- a/packages/console/src/components/ConnectorForm/ConfigForm/ConfigFormFields/index.tsx +++ b/packages/console/src/components/ConnectorForm/ConfigForm/ConfigFormFields/index.tsx @@ -17,7 +17,7 @@ import { jsonValidator } from '@/utils/validator'; import * as styles from './index.module.scss'; type Props = { - formItems: ConnectorConfigFormItem[]; + readonly formItems: ConnectorConfigFormItem[]; }; function ConfigFormFields({ formItems }: Props) { diff --git a/packages/console/src/components/ConnectorForm/ConfigForm/index.module.scss b/packages/console/src/components/ConnectorForm/ConfigForm/index.module.scss index ccd87402f81..ff7d8279aea 100644 --- a/packages/console/src/components/ConnectorForm/ConfigForm/index.module.scss +++ b/packages/console/src/components/ConnectorForm/ConfigForm/index.module.scss @@ -1,9 +1,5 @@ @use '@/scss/underscore' as _; -.copyToClipboard { - display: block; -} - .description { color: var(--color-text-secondary); font: var(--font-body-2); diff --git a/packages/console/src/components/ConnectorForm/ConfigForm/index.tsx b/packages/console/src/components/ConnectorForm/ConfigForm/index.tsx index ac70c1e0ff6..b13a45d524e 100644 --- a/packages/console/src/components/ConnectorForm/ConfigForm/index.tsx +++ b/packages/console/src/components/ConnectorForm/ConfigForm/index.tsx @@ -19,11 +19,11 @@ import ConfigFormFields from './ConfigFormFields'; import * as styles from './index.module.scss'; type Props = { - formItems?: ConnectorConfigFormItem[]; - className?: string; - connectorId: string; - connectorFactoryId?: string; - connectorType?: ConnectorType; + readonly formItems?: ConnectorConfigFormItem[]; + readonly className?: string; + readonly connectorId: string; + readonly connectorFactoryId?: string; + readonly connectorType?: ConnectorType; }; function ConfigForm({ @@ -56,7 +56,7 @@ function ConfigForm({ tip={conditional(!isSamlConnector && t('connectors.guide.callback_uri_description'))} > diff --git a/packages/console/src/components/ConnectorLogo/index.tsx b/packages/console/src/components/ConnectorLogo/index.tsx index abd673d4e78..333cbcff5de 100644 --- a/packages/console/src/components/ConnectorLogo/index.tsx +++ b/packages/console/src/components/ConnectorLogo/index.tsx @@ -8,9 +8,9 @@ import useTheme from '@/hooks/use-theme'; import * as styles from './index.module.scss'; type Props = { - className?: string; - data: Pick; - size?: 'small' | 'medium' | 'large'; + readonly className?: string; + readonly data: Pick; + readonly size?: 'small' | 'medium' | 'large'; }; function ConnectorLogo({ className, data, size = 'medium' }: Props) { diff --git a/packages/console/src/components/ConnectorTester/index.tsx b/packages/console/src/components/ConnectorTester/index.tsx index 38610985764..3e88f51c43f 100644 --- a/packages/console/src/components/ConnectorTester/index.tsx +++ b/packages/console/src/components/ConnectorTester/index.tsx @@ -18,11 +18,11 @@ import { trySubmitSafe } from '@/utils/form'; import * as styles from './index.module.scss'; type Props = { - connectorFactoryId: string; - connectorType: Exclude; - className?: string; - parse: () => unknown; - updateUsage?: () => void; + readonly connectorFactoryId: string; + readonly connectorType: Exclude; + readonly className?: string; + readonly parse: () => unknown; + readonly updateUsage?: () => void; }; type FormData = { diff --git a/packages/console/src/components/ContactUsPhraseLink/index.tsx b/packages/console/src/components/ContactUsPhraseLink/index.tsx index 6deaa4fa157..376d5d57274 100644 --- a/packages/console/src/components/ContactUsPhraseLink/index.tsx +++ b/packages/console/src/components/ContactUsPhraseLink/index.tsx @@ -4,7 +4,7 @@ import { contactEmailLink } from '@/consts'; import TextLink from '@/ds-components/TextLink'; type Props = { - children?: ReactNode; + readonly children?: ReactNode; }; function ContactUsPhraseLink({ children }: Props) { diff --git a/packages/console/src/components/Conversion/index.tsx b/packages/console/src/components/Conversion/index.tsx index 450249c1c25..3414963d028 100644 --- a/packages/console/src/components/Conversion/index.tsx +++ b/packages/console/src/components/Conversion/index.tsx @@ -3,20 +3,16 @@ import { Helmet } from 'react-helmet'; import useCurrentUser from '@/hooks/use-current-user'; -import { useRetry } from './use-retry'; import { shouldReport, gtagAwTrackingId, redditPixelId, hashEmail, - type GtagConversionId, - type RedditReportType, - reportToGoogle, - reportToReddit, + plausibleDataDomain, } from './utils'; type ScriptProps = { - userEmailHash?: string; + readonly userEmailHash?: string; }; function GoogleScripts({ userEmailHash }: ScriptProps) { @@ -61,6 +57,20 @@ function RedditScripts({ userEmailHash }: ScriptProps) { ); } +function PlausibleScripts() { + return ( + + + + ); +} + /** * Renders global scripts for conversion tracking. */ @@ -88,30 +98,9 @@ export function GlobalScripts() { return ( <> + ); } - -type ReportConversionOptions = { - transactionId?: string; - gtagId?: GtagConversionId; - redditType?: RedditReportType; -}; - -export const useReportConversion = ({ - gtagId, - redditType, - transactionId, -}: ReportConversionOptions) => { - useRetry({ - precondition: Boolean(shouldReport && gtagId), - execute: () => (gtagId ? reportToGoogle(gtagId, { transactionId }) : false), - }); - - useRetry({ - precondition: Boolean(shouldReport && redditType), - execute: () => (redditType ? reportToReddit(redditType) : false), - }); -}; diff --git a/packages/console/src/components/Conversion/utils.ts b/packages/console/src/components/Conversion/utils.ts index 16b73f467af..730ab9f4915 100644 --- a/packages/console/src/components/Conversion/utils.ts +++ b/packages/console/src/components/Conversion/utils.ts @@ -1,6 +1,7 @@ import { cond } from '@silverhand/essentials'; +import debug from 'debug'; -import { isProduction } from '@/consts/env'; +const log = debug('conversion'); export const gtagAwTrackingId = 'AW-11124811245'; export enum GtagConversionId { @@ -13,6 +14,7 @@ export enum GtagConversionId { } export const redditPixelId = 't2_ggt11omdo'; +export const plausibleDataDomain = 'logto.io'; const logtoProductionHostname = 'logto.io'; @@ -53,30 +55,18 @@ export const hashEmail = async (email?: string) => { return sha256(canonicalizedEmail); }; -/** Print debug message if not in production. */ -const debug = (...args: Parameters<(typeof console)['debug']>) => { - if (!isProduction) { - console.debug(...args); - } -}; - /** * Add more if needed: https://reddit.my.site.com/helpcenter/s/article/Install-the-Reddit-Pixel-on-your-website */ -export type RedditReportType = - | 'PageVisit' - | 'ViewContent' - | 'Search' - | 'Purchase' - | 'Lead' - | 'SignUp'; - -export const reportToReddit = (redditType: RedditReportType) => { +type RedditReportType = 'PageVisit' | 'ViewContent' | 'Search' | 'Purchase' | 'Lead' | 'SignUp'; + +const reportToReddit = (redditType: RedditReportType) => { if (!window.rdt) { + log('report:', 'window.rdt is not available'); return false; } - debug('report:', 'redditType =', redditType); + log('report:', 'redditType =', redditType); window.rdt('track', redditType); return true; @@ -87,13 +77,14 @@ export const reportToGoogle = ( { transactionId }: { transactionId?: string } = {} ) => { if (!window.gtag) { + log('report:', 'window.gtag is not available'); return false; } const run = async () => { const transaction = cond(transactionId && { transaction_id: await sha256(transactionId) }); - debug('report:', 'gtagId =', gtagId, 'transaction =', transaction); + log('report:', 'gtagId =', gtagId, 'transaction =', transaction); window.gtag?.('event', 'conversion', { send_to: gtagId, ...transaction, @@ -104,3 +95,25 @@ export const reportToGoogle = ( return true; }; + +type ReportConversionOptions = { + transactionId?: string; + gtagId?: GtagConversionId; + redditType?: RedditReportType; +}; + +export const reportConversion = async ({ + gtagId, + redditType, + transactionId, +}: ReportConversionOptions) => { + if (!shouldReport) { + log('skip reporting conversion:', { gtagId, redditType, transactionId }); + return; + } + + return Promise.all([ + gtagId ? reportToGoogle(gtagId, { transactionId }) : undefined, + redditType ? reportToReddit(redditType) : undefined, + ]); +}; diff --git a/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/ConnectorRadio/index.tsx b/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/ConnectorRadio/index.tsx index c7d89429670..b722f3f99bb 100644 --- a/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/ConnectorRadio/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/ConnectorRadio/index.tsx @@ -8,7 +8,7 @@ import { type ConnectorGroup } from '@/types/connector'; import * as styles from './index.module.scss'; type Props = { - data: ConnectorGroup; + readonly data: ConnectorGroup; }; function ConnectorRadio({ data: { name, logo, logoDark, description } }: Props) { diff --git a/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/index.tsx b/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/index.tsx index 4c469e0a621..f682f929e8a 100644 --- a/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/ConnectorRadioGroup/index.tsx @@ -10,11 +10,11 @@ import * as styles from './index.module.scss'; export type ConnectorRadioGroupSize = 'medium' | 'large' | 'xlarge'; type Props = { - name: string; - value?: string; - groups: Array>; - size: ConnectorRadioGroupSize; - onChange: (groupId: string) => void; + readonly name: string; + readonly value?: string; + readonly groups: Array>; + readonly size: ConnectorRadioGroupSize; + readonly onChange: (groupId: string) => void; }; function ConnectorRadioGroup({ name, groups, value, size, onChange }: Props) { diff --git a/packages/console/src/components/CreateConnectorForm/Footer/index.tsx b/packages/console/src/components/CreateConnectorForm/Footer/index.tsx index 50500d53c42..53c7b4032cb 100644 --- a/packages/console/src/components/CreateConnectorForm/Footer/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/Footer/index.tsx @@ -16,11 +16,11 @@ import { type ConnectorGroup } from '@/types/connector'; import { hasReachedQuotaLimit } from '@/utils/quota'; type Props = { - isCreatingSocialConnector: boolean; - existingConnectors: ConnectorResponse[]; - selectedConnectorGroup?: ConnectorGroup; - isCreateButtonDisabled: boolean; - onClickCreateButton: () => void; + readonly isCreatingSocialConnector: boolean; + readonly existingConnectors: ConnectorResponse[]; + readonly selectedConnectorGroup?: ConnectorGroup; + readonly isCreateButtonDisabled: boolean; + readonly onClickCreateButton: () => void; }; function Footer({ diff --git a/packages/console/src/components/CreateConnectorForm/PlatformSelector/index.tsx b/packages/console/src/components/CreateConnectorForm/PlatformSelector/index.tsx index 64a805175fa..658db4e286d 100644 --- a/packages/console/src/components/CreateConnectorForm/PlatformSelector/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/PlatformSelector/index.tsx @@ -9,9 +9,9 @@ import type { ConnectorGroup } from '@/types/connector'; import * as styles from './index.module.scss'; type Props = { - connectorGroup: ConnectorGroup; - connectorId?: string; - onConnectorIdChange: (value: string) => void; + readonly connectorGroup: ConnectorGroup; + readonly connectorId?: string; + readonly onConnectorIdChange: (value: string) => void; }; function PlatformSelector({ connectorGroup, connectorId, onConnectorIdChange }: Props) { diff --git a/packages/console/src/components/CreateConnectorForm/Skeleton/index.tsx b/packages/console/src/components/CreateConnectorForm/Skeleton/index.tsx index 162c13f1bd4..9ecdb68975f 100644 --- a/packages/console/src/components/CreateConnectorForm/Skeleton/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/Skeleton/index.tsx @@ -6,7 +6,7 @@ import * as radioGroupStyles from '../ConnectorRadioGroup/index.module.scss'; import * as styles from './index.module.scss'; type Props = { - numberOfLoadingConnectors?: number; + readonly numberOfLoadingConnectors?: number; }; function Skeleton({ numberOfLoadingConnectors = 8 }: Props) { diff --git a/packages/console/src/components/CreateConnectorForm/index.tsx b/packages/console/src/components/CreateConnectorForm/index.tsx index c72f3883130..5ed33692ef9 100644 --- a/packages/console/src/components/CreateConnectorForm/index.tsx +++ b/packages/console/src/components/CreateConnectorForm/index.tsx @@ -22,9 +22,9 @@ import * as styles from './index.module.scss'; import { compareConnectors, getConnectorRadioGroupSize, getModalTitle } from './utils'; type Props = { - isOpen: boolean; - type?: ConnectorType; - onClose?: (connectorId?: string) => void; + readonly isOpen: boolean; + readonly type?: ConnectorType; + readonly onClose?: (connectorId?: string) => void; }; function CreateConnectorForm({ onClose, isOpen: isFormOpen, type }: Props) { diff --git a/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx b/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx index 88e1e7f2f00..f9d6a10549d 100644 --- a/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx +++ b/packages/console/src/components/CreateTenantModal/EnvTagOptionContent/index.tsx @@ -10,7 +10,7 @@ import { ReservedPlanName } from '@/types/subscriptions'; import * as styles from './index.module.scss'; type Props = { - tag: TenantTag; + readonly tag: TenantTag; }; const descriptionMap: Record = { diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/FeaturedPlanContent/index.tsx b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/FeaturedPlanContent/index.tsx index 4057f726dbe..8e95a6a2a3a 100644 --- a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/FeaturedPlanContent/index.tsx +++ b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/FeaturedPlanContent/index.tsx @@ -7,7 +7,7 @@ import * as styles from './index.module.scss'; import useFeaturedPlanContent from './use-featured-plan-content'; type Props = { - planId: string; + readonly planId: string; }; function FeaturedPlanContent({ planId }: Props) { diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/index.tsx b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/index.tsx index 01362bd6732..53469b4d502 100644 --- a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/index.tsx +++ b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/PlanCardItem/index.tsx @@ -18,8 +18,8 @@ import FeaturedPlanContent from './FeaturedPlanContent'; import * as styles from './index.module.scss'; type Props = { - plan: SubscriptionPlan; - onSelect: () => void; + readonly plan: SubscriptionPlan; + readonly onSelect: () => void; }; function PlanCardItem({ plan, onSelect }: Props) { diff --git a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/index.tsx b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/index.tsx index 8609fede8b7..20434640c1f 100644 --- a/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/index.tsx +++ b/packages/console/src/components/CreateTenantModal/SelectTenantPlanModal/index.tsx @@ -22,8 +22,8 @@ import PlanCardItem from './PlanCardItem'; import * as styles from './index.module.scss'; type Props = { - tenantData?: CreateTenantData; - onClose: (tenant?: TenantResponse) => void; + readonly tenantData?: CreateTenantData; + readonly onClose: (tenant?: TenantResponse) => void; }; function SelectTenantPlanModal({ tenantData, onClose }: Props) { diff --git a/packages/console/src/components/CreateTenantModal/index.tsx b/packages/console/src/components/CreateTenantModal/index.tsx index fd3c7a3dfe8..f7c08bfbc1d 100644 --- a/packages/console/src/components/CreateTenantModal/index.tsx +++ b/packages/console/src/components/CreateTenantModal/index.tsx @@ -23,8 +23,8 @@ import * as styles from './index.module.scss'; import { type CreateTenantData } from './type'; type Props = { - isOpen: boolean; - onClose: (tenant?: TenantResponse) => void; + readonly isOpen: boolean; + readonly onClose: (tenant?: TenantResponse) => void; }; const availableTags = [TenantTag.Development, TenantTag.Production]; diff --git a/packages/console/src/components/DateTime/index.tsx b/packages/console/src/components/DateTime/index.tsx index 7fc43947ea1..766d94d28d7 100644 --- a/packages/console/src/components/DateTime/index.tsx +++ b/packages/console/src/components/DateTime/index.tsx @@ -2,7 +2,7 @@ import type { Nullable } from '@silverhand/essentials'; import { isValid } from 'date-fns'; type Props = { - children: Nullable; + readonly children: Nullable; }; function DateTime({ children }: Props) { diff --git a/packages/console/src/components/DeleteConnectorConfirmModal/index.tsx b/packages/console/src/components/DeleteConnectorConfirmModal/index.tsx index 34cfb648c31..89b4721b146 100644 --- a/packages/console/src/components/DeleteConnectorConfirmModal/index.tsx +++ b/packages/console/src/components/DeleteConnectorConfirmModal/index.tsx @@ -6,12 +6,12 @@ import UnnamedTrans from '@/components/UnnamedTrans'; import ConfirmModal from '@/ds-components/ConfirmModal'; type Props = { - data: ConnectorResponse; - isOpen: boolean; - isInUse: boolean; - isLoading: boolean; - onCancel: () => void; - onConfirm: () => void; + readonly data: ConnectorResponse; + readonly isOpen: boolean; + readonly isInUse: boolean; + readonly isLoading: boolean; + readonly onCancel: () => void; + readonly onConfirm: () => void; }; function DeleteConnectorConfirmModal({ diff --git a/packages/console/src/components/DetailsForm/index.tsx b/packages/console/src/components/DetailsForm/index.tsx index d4d24e558bf..c19161d60c4 100644 --- a/packages/console/src/components/DetailsForm/index.tsx +++ b/packages/console/src/components/DetailsForm/index.tsx @@ -6,12 +6,12 @@ import SubmitFormChangesActionBar from '../SubmitFormChangesActionBar'; import * as styles from './index.module.scss'; type Props = { - autoComplete?: string; - isDirty: boolean; - isSubmitting: boolean; - onSubmit: () => Promise; - onDiscard: () => void; - children: ReactNode; + readonly autoComplete?: string; + readonly isDirty: boolean; + readonly isSubmitting: boolean; + readonly onSubmit: () => Promise; + readonly onDiscard: () => void; + readonly children: ReactNode; }; function DetailsForm({ diff --git a/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx b/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx index d3e01bd8799..b4b431a33a9 100644 --- a/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx +++ b/packages/console/src/components/DetailsPage/DetailsPageHeader/index.tsx @@ -53,45 +53,45 @@ type Props = { /** * The main 60x60 icon on the very left */ - icon: ReactElement; + readonly icon: ReactElement; /** * The main title of the header */ - title: ReactNode; + readonly title: ReactNode; /** * Shows a subtitle in the second row * Example usage: Secondary information of the user (if any) in user details page */ - subtitle?: ReactNode; + readonly subtitle?: ReactNode; /** * Shows a tag in the second row of the header metadata * Example usage: Application type "Native / SPA / Traditional" */ - primaryTag?: ReactNode; + readonly primaryTag?: ReactNode; /** * Shows a status tag in the second row of the header metadata * Example usage: Connector status "In use / Not in use" in connector details page */ - statusTag?: StatusTag; + readonly statusTag?: StatusTag; /** * Shows the entity identifier in a "Copy to clipboard" component * Example usage: "App ID" in application details page */ - identifier?: Identifier; + readonly identifier?: Identifier; /** * Shows an additional action button in the header, next to the "...(More)" button * Example usage: "Check Guide" button in application details page */ - additionalActionButton?: AdditionalActionButton; + readonly additionalActionButton?: AdditionalActionButton; /** * Shows additional custom element in the header, next to the "...(More)" button * Example usage (special use case): "Total email sent (count)" in Logto email connector */ - additionalCustomElement?: ReactElement; + readonly additionalCustomElement?: ReactElement; /** * Dropdown action menu items nested in the "...(More)" button */ - actionMenuItems?: MenuItem[]; + readonly actionMenuItems?: MenuItem[]; }; function DetailsPageHeader({ diff --git a/packages/console/src/components/DetailsPage/index.tsx b/packages/console/src/components/DetailsPage/index.tsx index 96a8105a3cd..c6083122d0f 100644 --- a/packages/console/src/components/DetailsPage/index.tsx +++ b/packages/console/src/components/DetailsPage/index.tsx @@ -15,13 +15,13 @@ import Skeleton from './Skeleton'; import * as styles from './index.module.scss'; type Props = { - backLink: To; - backLinkTitle?: AdminConsoleKey | ReactElement; - isLoading?: boolean; - error?: RequestError; - onRetry?: () => void; - children: ReactNode; - className?: string; + readonly backLink: To; + readonly backLinkTitle?: AdminConsoleKey | ReactElement; + readonly isLoading?: boolean; + readonly error?: RequestError; + readonly onRetry?: () => void; + readonly children: ReactNode; + readonly className?: string; }; function DetailsPage({ diff --git a/packages/console/src/components/DomainStatusTag/index.tsx b/packages/console/src/components/DomainStatusTag/index.tsx index ef0e0a82908..3ad3c458159 100644 --- a/packages/console/src/components/DomainStatusTag/index.tsx +++ b/packages/console/src/components/DomainStatusTag/index.tsx @@ -16,7 +16,7 @@ const domainStatusToTag: Record< }; type Props = { - status: DomainStatus; + readonly status: DomainStatus; }; function DomainStatusTag({ status }: Props) { diff --git a/packages/console/src/components/Drawer/index.tsx b/packages/console/src/components/Drawer/index.tsx index 219db484b16..99e004b664d 100644 --- a/packages/console/src/components/Drawer/index.tsx +++ b/packages/console/src/components/Drawer/index.tsx @@ -9,11 +9,11 @@ import Spacer from '@/ds-components/Spacer'; import * as styles from './index.module.scss'; type Props = { - title?: AdminConsoleKey; - subtitle?: AdminConsoleKey; - isOpen: boolean; - children: React.ReactNode; - onClose?: () => void; + readonly title?: AdminConsoleKey; + readonly subtitle?: AdminConsoleKey; + readonly isOpen: boolean; + readonly children: React.ReactNode; + readonly onClose?: () => void; }; function Drawer({ title, subtitle, isOpen, children, onClose }: Props) { diff --git a/packages/console/src/components/EditScopeModal/index.tsx b/packages/console/src/components/EditScopeModal/index.tsx new file mode 100644 index 00000000000..1ba3bc37bee --- /dev/null +++ b/packages/console/src/components/EditScopeModal/index.tsx @@ -0,0 +1,99 @@ +import { type AdminConsoleKey } from '@logto/phrases'; +import { type Nullable } from '@silverhand/essentials'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Button from '@/ds-components/Button'; +import FormField from '@/ds-components/FormField'; +import ModalLayout from '@/ds-components/ModalLayout'; +import TextInput from '@/ds-components/TextInput'; +import * as modalStyles from '@/scss/modal.module.scss'; +import { trySubmitSafe } from '@/utils/form'; + +export type EditScopeData = { + /** Only `description` is editable for all kinds of scopes */ + description: Nullable; +}; + +type Props = { + /** The scope name displayed in the name input field */ + readonly scopeName: string; + /** The data to edit */ + readonly data: EditScopeData; + /** Determines the translation keys for texts in the editor modal */ + readonly text: { + /** The translation key of the modal title */ + title: AdminConsoleKey; + /** The field name translation key for the name input */ + nameField: AdminConsoleKey; + /** The field name translation key for the description input */ + descriptionField: AdminConsoleKey; + /** The placeholder translation key for the description input */ + descriptionPlaceholder: AdminConsoleKey; + }; + readonly onSubmit: (editedData: EditScopeData) => Promise; + readonly onClose: () => void; +}; + +function EditScopeModal({ scopeName, data, text, onClose, onSubmit }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const { + handleSubmit, + register, + formState: { isSubmitting }, + } = useForm({ defaultValues: data }); + + const onSubmitHandler = handleSubmit( + trySubmitSafe(async (formData) => { + await onSubmit(formData); + onClose(); + }) + ); + + return ( + { + onClose(); + }} + > + +