diff --git a/login/.changeset/README.md b/login/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/login/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/login/.changeset/config.json b/login/.changeset/config.json new file mode 100644 index 0000000000..3f2d313f66 --- /dev/null +++ b/login/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@zitadel/login"] +} diff --git a/login/.dockerignore b/login/.dockerignore new file mode 100644 index 0000000000..b328b97284 --- /dev/null +++ b/login/.dockerignore @@ -0,0 +1,2 @@ +/* +!/docker diff --git a/login/.eslintrc.cjs b/login/.eslintrc.cjs new file mode 100644 index 0000000000..1bfcec169d --- /dev/null +++ b/login/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `@zitadel/eslint-config` + extends: ["@zitadel/eslint-config"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/login/.github/ISSUE_TEMPLATE/bug.yaml b/login/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000000..2764c1a365 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,63 @@ +name: 🐛 Bug Report +description: "Create a bug report to help us improve ZITADEL Typescript Library." +title: "[Bug]: " +labels: ["bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the documentation, the existing issues or discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + validations: + required: true +- type: input + id: version + attributes: + label: Version + description: Which version of ZITADEL Typescript Library are you using. +- type: textarea + id: impact + attributes: + label: Describe the problem caused by this bug + description: A clear and concise description of the problem you have and what the bug is. + validations: + required: true +- type: textarea + id: reproduce + attributes: + label: To reproduce + description: Steps to reproduce the behaviour + placeholder: | + Steps to reproduce the behavior: + 1. ... + validations: + required: true +- type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. +- type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. +- type: textarea + id: config + attributes: + label: Relevant Configuration + description: Add any relevant configurations that could help us. Make sure to redact any sensitive information. +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/config.yml b/login/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..7e690b9344 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 ZITADEL Community Chat + url: https://zitadel.com/chat diff --git a/login/.github/ISSUE_TEMPLATE/docs.yaml b/login/.github/ISSUE_TEMPLATE/docs.yaml new file mode 100644 index 0000000000..04c1c0cdb1 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/docs.yaml @@ -0,0 +1,30 @@ +name: 📄 Documentation +description: Create an issue for missing or wrong documentation. +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this issue. + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: docs + attributes: + label: Describe the docs your are missing or that are wrong + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/improvement.yaml b/login/.github/ISSUE_TEMPLATE/improvement.yaml new file mode 100644 index 0000000000..cfe79d407b --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/improvement.yaml @@ -0,0 +1,54 @@ +name: 🛠️ Improvement +description: "Create an new issue for an improvment in ZITADEL" +labels: ["improvement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this improvement request + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this improvement is supposed to solve. + placeholder: Describe the problem you have + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the typescript library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/proposal.yaml b/login/.github/ISSUE_TEMPLATE/proposal.yaml new file mode 100644 index 0000000000..cd9ff66972 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/proposal.yaml @@ -0,0 +1,54 @@ +name: 💡 Proposal / Feature request +description: "Create an issue for a feature request/proposal." +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this proposal / feature reqeust + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this proposal / feature is supposed to solve. + placeholder: Describe the problem you have. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the Typescript Library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/dependabot.yml b/login/.github/dependabot.yml new file mode 100644 index 0000000000..8f3906c179 --- /dev/null +++ b/login/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: '/' + open-pull-requests-limit: 1 + schedule: + interval: 'daily' + + - package-ecosystem: npm + directory: '/' + open-pull-requests-limit: 3 + schedule: + interval: daily + groups: + prod: + dependency-type: production + dev: + dependency-type: development + ignore: + - dependency-name: "eslint" + versions: [ "9.x" ] diff --git a/login/.github/pull_request_template.md b/login/.github/pull_request_template.md new file mode 100644 index 0000000000..138d4919af --- /dev/null +++ b/login/.github/pull_request_template.md @@ -0,0 +1,13 @@ +### Definition of Ready + +- [ ] I am happy with the code +- [ ] Short description of the feature/issue is added in the pr description +- [ ] PR is linked to the corresponding user story +- [ ] Acceptance criteria are met +- [ ] All open todos and follow ups are defined in a new ticket and justified +- [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented. +- [ ] Vitest unit tests ensure that components produce expected outputs on different inputs. +- [ ] Cypress integration tests ensure that login app pages work as expected on good and bad user inputs, ZITADEL responses or IDP redirects. The ZITADEL API is mocked, IDP redirects are simulated. +- [ ] Playwright acceptances tests ensure that the happy paths of common user journeys work as expected. The ZITADEL API is not mocked but IDP redirects are simulated. +- [ ] No debug or dead code +- [ ] My code has no repetitions diff --git a/login/.github/workflows/docker.yml b/login/.github/workflows/docker.yml new file mode 100644 index 0000000000..b8f37c0ce1 --- /dev/null +++ b/login/.github/workflows/docker.yml @@ -0,0 +1,109 @@ +name: Docker + +on: + push: + branches: + - main + - qa + workflow_dispatch: + +permissions: + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Cache turbo build setup + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: docker-container + + - name: Login Public + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login Private + uses: docker/login-action@v3 + with: + registry: ${{ secrets.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/zitadel/login + ${{ secrets.DOCKER_IMAGE }} + tags: | + type=edge + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=sha + + - name: Install dependencies + run: pnpm install + + - name: Generate stubs + run: pnpm generate + + - name: Build for Docker + run: NEXT_PUBLIC_BASE_PATH=/ui/v2/login pnpm build:docker + + - name: Build and Push Image + id: build + uses: docker/build-push-action@v5 + timeout-minutes: 10 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Export digest + run: | + mkdir -p /tmp/digests/app + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/app/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests + path: /tmp/digests + if-no-files-found: error + retention-days: 1 diff --git a/login/.github/workflows/issues.yml b/login/.github/workflows/issues.yml new file mode 100644 index 0000000000..480c3392bc --- /dev/null +++ b/login/.github/workflows/issues.yml @@ -0,0 +1,43 @@ +name: Add new issues to product management project + +on: + issues: + types: + - opened + pull_request_target: + types: + - opened + +jobs: + add-to-project: + name: Add issue and community pr to project + runs-on: ubuntu-latest + steps: + - name: add issue + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'issues' }} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: tspascoal/get-user-teams-membership@v3 + id: checkUserMember + if: github.actor != 'dependabot[bot]' + with: + username: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} + - name: add pr + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: actions-ecosystem/action-add-labels@v1.1.3 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} + with: + github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} + labels: | + os-contribution diff --git a/login/.github/workflows/release.yml b/login/.github/workflows/release.yml new file mode 100644 index 0000000000..70a6f89e70 --- /dev/null +++ b/login/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/login/.github/workflows/test.yml b/login/.github/workflows/test.yml new file mode 100644 index 0000000000..dac5fcee85 --- /dev/null +++ b/login/.github/workflows/test.yml @@ -0,0 +1,123 @@ +name: Quality + +on: + pull_request: + # schedule: + # Every morning at 6:00 AM CET + # - cron: '0 4 * * *' + workflow_dispatch: + inputs: + target-env: + description: 'Zitadel target environment to run the acceptance tests against.' + required: true + type: choice + options: + - 'qa' + - 'prod' + +jobs: + matrix: + # If the workflow is triggered by a schedule event, only the acceptance tests run against QA and Prod. + name: Matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Matrix + id: matrix + run: | + if [ -n "${{ github.event.schedule }}" ]; then + echo 'matrix=["test:acceptance:qa", "test:acceptance:prod"]' >> $GITHUB_OUTPUT + elif [ -n "${{ github.event.inputs.target-env }}" ]; then + echo 'matrix=["test:acceptance:${{ github.event.inputs.target-env }}"]' >> $GITHUB_OUTPUT + else + echo 'matrix=["format --check", "lint", "test:unit", "test:integration", "test:acceptance"]' >> $GITHUB_OUTPUT + fi + + quality: + name: Ensure Quality + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + permissions: + contents: "read" + + needs: + - matrix + + strategy: + fail-fast: false + matrix: + command: ${{ fromJson( needs.matrix.outputs.matrix ) }} + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Buf + uses: bufbuild/buf-setup-action@v1.45.0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + + - name: Setup Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + # We can cache the Playwright binary independently from the pnpm cache, because we install it separately. + # After pnpm install --frozen-lockfile, we can get the version so we only have to download the binary once per version. + - run: echo "PLAYWRIGHT_VERSION=$(npx playwright --version | cut -d ' ' -f 2)" >> $GITHUB_ENV + if: ${{ startsWith(matrix.command, 'test:acceptance') }} + + - name: Setup Playwright binary cache + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-binary-${{ env.PLAYWRIGHT_VERSION }} + restore-keys: | + ${{ runner.os }}-playwright-binary- + if: ${{ startsWith(matrix.command, 'test:acceptance') }} + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + if: ${{ startsWith(matrix.command, 'test:acceptance') && steps.playwright-cache.outputs.cache-hit != 'true' }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + if: ${{ matrix.command == 'test:acceptance' }} + + - name: Run ZITADEL + run: ZITADEL_DEV_UID=root pnpm run-sink + if: ${{ matrix.command == 'test:acceptance' }} + + - name: Create Cloud Env File + run: | + if [ "${{ matrix.command }}" == "test:acceptance:prod" ]; then + echo "${{ secrets.ENV_FILE_CONTENT_ACCEPTANCE_PROD }}" | tee apps/login/.env.local acceptance/tests/.env.local > /dev/null + else + echo "${{ secrets.ENV_FILE_CONTENT_ACCEPTANCE_QA }}" | tee apps/login/.env.local acceptance/tests/.env.local > /dev/null + fi + if: ${{ matrix.command == 'test:acceptance:qa' || matrix.command == 'test:acceptance:prod' }} + + - name: Create Production Build + run: pnpm build + if: ${{ startsWith(matrix.command, 'test:acceptance') }} + + - name: Run SAML SP + run: ZITADEL_DEV_UID=root pnpm run-samlsp + if: ${{ matrix.command == 'test:acceptance' }} + + - name: Run OIDC RP + run: ZITADEL_DEV_UID=root pnpm run-oidcrp + if: ${{ matrix.command == 'test:acceptance' }} + + - name: Check + id: check + run: pnpm ${{ contains(matrix.command, 'test:acceptance') && 'test:acceptance' || matrix.command }} diff --git a/login/.gitignore b/login/.gitignore new file mode 100644 index 0000000000..cedeed9b03 --- /dev/null +++ b/login/.gitignore @@ -0,0 +1,26 @@ +.DS_Store +node_modules +.turbo +*.log +.next +dist +dist-ssr +*.local +.env +apps/login/.env.local +apps/login/.env.acceptance +.cache +server/dist +public/dist +.turbo +packages/zitadel-server/src/app/proto +.vscode +.idea +.vercel +.env*.local +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/out +/docker diff --git a/login/.npmrc b/login/.npmrc new file mode 100644 index 0000000000..ded82e2f63 --- /dev/null +++ b/login/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/login/.nvmrc b/login/.nvmrc new file mode 100644 index 0000000000..0a47c855eb --- /dev/null +++ b/login/.nvmrc @@ -0,0 +1 @@ +lts/iron \ No newline at end of file diff --git a/login/.prettierignore b/login/.prettierignore new file mode 100644 index 0000000000..a37ac4ff79 --- /dev/null +++ b/login/.prettierignore @@ -0,0 +1,8 @@ +.next/ +.changeset/ +.github/ +dist/ +packages/zitadel-proto/google +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate +packages/zitadel-proto/zitadel diff --git a/login/.prettierrc b/login/.prettierrc new file mode 100644 index 0000000000..6d0c388d7a --- /dev/null +++ b/login/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 125, + "trailingComma": "all", + "plugins": ["prettier-plugin-organize-imports"] +} + \ No newline at end of file diff --git a/login/CODE_OF_CONDUCT.md b/login/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..ac3f129652 --- /dev/null +++ b/login/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +legal@zitadel.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/login/CONTRIBUTING.md b/login/CONTRIBUTING.md new file mode 100644 index 0000000000..f69f76f9bd --- /dev/null +++ b/login/CONTRIBUTING.md @@ -0,0 +1,109 @@ +# Contributing + +:attention: In this CONTRIBUTING.md you read about contributing to this very repository. +If you want to develop your own login UI, please refer [to the README.md](./README.md). + +## Introduction + +Thank you for your interest about how to contribute! + +:attention: If you notice a possible **security vulnerability**, please don't hesitate to disclose any concern by contacting [security@zitadel.com](mailto:security@zitadel.com). +You don't have to be perfectly sure about the nature of the vulnerability. +We will give them a high priority and figure them out. + +We also appreciate all your other ideas, thoughts and feedback and will take care of them as soon as possible. +We love to discuss in an open space using [GitHub issues](https://github.com/zitadel/typescript/issues), +[GitHub discussions in the core repo](https://github.com/zitadel/zitadel/discussions) +or in our [chat on Discord](https://zitadel.com/chat). +For private discussions, +you have [more contact options on our Website](https://zitadel.com/contact). + +## Pull Requests + +Please consider the following guidelines when creating a pull request. + +- The latest changes are always in `main`, so please make your pull request against that branch. +- pull requests should be raised for any change +- Pull requests need approval of a ZITADEL core engineer @zitadel/engineers before merging +- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) +- If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request + +## Setting Up The ZITADEL API + +If you want to have a one-liner to get you up and running, +or if you want to develop against a ZITADEL API with the latest features, +or even add changes to ZITADEL itself at the same time, +you should develop against your local ZITADEL process. +However, it might be easier to develop against your ZITADEL Cloud instance +if you don't have docker installed +or have limited resources on your local machine. + +### Developing Against Your Local ZITADEL Instance + +```sh +# To have your service user key and environment file written with the correct ownership, export your current users ID. +export ZITADEL_DEV_UID="$(id -u)" + +# Pull images +docker compose --file ./acceptance/docker-compose.yaml pull + +# Run ZITADEL with local notification sink and configure ./apps/login/.env.local +pnpm run-sink +``` + +### Developing Against Your ZITADEL Cloud Instance + +Configure your shell by exporting the following environment variables: + +```sh +export ZITADEL_API_URL= +export ZITADEL_ORG_ID= +export ZITADEL_SERVICE_USER_TOKEN= +``` + +### Setting up local environment + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server +pnpm dev +``` + +The application is now available at `http://localhost:3000` + +### Adding applications and IDPs + +```sh +# OPTIONAL Run SAML SP +pnpm run-samlsp + +# OPTIONAL Run OIDC RP +pnpm run-oidcrp + +# OPTIONAL Run SAML IDP +pnpm run-samlidp + +# OPTIONAL Run OIDC OP +pnpm run-oidcop +``` + +### Testing + +You can execute the following commands `pnpm test` for a single test run or `pnpm test:watch` in the following directories: + +- apps/login +- packages/zitadel-proto +- packages/zitadel-client +- packages/zitadel-node +- The projects root directory: all tests in the project are executed + +In apps/login, these commands also spin up the application and a ZITADEL gRPC API mock server to run integration tests using [Cypress](https://www.cypress.io/) against them. +If you want to run the integration tests standalone against an environment of your choice, navigate to ./apps/login, [configure your shell as you like](# Developing Against Your ZITADEL Cloud Instance) and run `pnpm test:integration:run` or `pnpm test:integration:open`. +Then you need to lifecycle the mock process using the command `pnpm mock` or the more fine grained commands `pnpm mock:build`, `pnpm mock:build:nocache`, `pnpm mock:run` and `pnpm mock:destroy`. + +That's it! 🎉 diff --git a/login/Dockerfile b/login/Dockerfile new file mode 100644 index 0000000000..65f3326053 --- /dev/null +++ b/login/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. +RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file + +COPY --chown=nextjs:nodejs ./docker/apps/login/.next/standalone ./ +COPY --chown=nextjs:nodejs ./docker/apps/login/.next/static ./apps/login/.next/static +COPY --chown=nextjs:nodejs ./docker/apps/login/public ./apps/login/public + +USER nextjs +ENV HOSTNAME="0.0.0.0" + +CMD ["/bin/sh", "-c", " set -o allexport && . /.env-file/.env && set +o allexport && node apps/login/server.js"] diff --git a/login/LICENSE b/login/LICENSE new file mode 100644 index 0000000000..89f750f2ab --- /dev/null +++ b/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 ZITADEL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/login/README.md b/login/README.md new file mode 100644 index 0000000000..5007ddf2ab --- /dev/null +++ b/login/README.md @@ -0,0 +1,258 @@ +# ZITADEL TypeScript with Turborepo + +This repository contains all TypeScript and JavaScript packages and applications you need to create your own ZITADEL +Login UI. + +collage of login screens + +[![npm package](https://img.shields.io/npm/v/@zitadel/proto.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/proto) +[![npm package](https://img.shields.io/npm/v/@zitadel/client.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/client) + +**⚠️ This repo and packages are in beta state and subject to change ⚠️** + +The scope of functionality of this repo and packages is under active development. + +The `@zitadel/client` package is using [@connectrpc/connect](https://github.com/connectrpc/connect-es#readme). + +You can read the [contribution guide](/CONTRIBUTING.md) on how to contribute. +Questions can be raised in our [Discord channel](https://discord.gg/erh5Brh7jE) or as +a [GitHub issue](https://github.com/zitadel/typescript/issues). + +## Developing Your Own ZITADEL Login UI + +We think the easiest path of getting up and running, is the following: + +1. Fork and clone this repository +1. [Run the ZITADEL Cloud login UI locally](#run-login-ui) +1. Make changes to the code and see the effects live on your local machine +1. Study the rest of this README.md and get familiar and comfortable with how everything works. +1. Decide on a way of how you want to build and run your login UI. + You can reuse ZITADEL Clouds way. + But if you need more freedom, you can also import the packages you need into your self built application. + +## Included Apps And Packages + +- `login`: The login UI used by ZITADEL Cloud, powered by Next.js +- `@zitadel/client`: shared client utilities for node and browser environments +- `@zitadel/proto`: Protocol Buffers (proto) definitions used by ZITADEL projects +- `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo +- `@zitadel/eslint-config`: ESLint preset + +Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Login + +The login is currently in a work in progress state. +The goal is to implement a login UI, using the session API of ZITADEL, which also implements the OIDC Standard and is +ready to use for everyone. + +In the first phase we want to have a MVP login ready with the OIDC Standard and a basic feature set. In a second step +the features will be extended. + +This list should show the current implementation state, and also what is missing. +You can already use the current state, and extend it with your needs. + +#### Features list + +- [x] Local User Registration (with Password) +- [x] User Registration and Login with external Provider + - [x] Google + - [x] GitHub + - [x] GitHub Enterprise + - [x] GitLab + - [x] GitLab Enterprise + - [x] Azure + - [x] Apple + - [x] Generic OIDC + - [x] Generic OAuth + - [x] Generic JWT + - [ ] LDAP + - [ ] SAML SP +- Multifactor Registration an Login + - [x] Passkeys + - [x] TOTP + - [x] OTP: Email Code + - [x] OTP: SMS Code +- [x] Password Change/Reset +- [x] Domain Discovery +- [x] Branding +- OIDC Standard + + - [x] Authorization Code Flow with PKCE + - [x] AuthRequest `hintUserId` + - [x] AuthRequest `loginHint` + - [x] AuthRequest `prompt` + - [x] Login + - [x] Select Account + - [ ] Consent + - [x] Create + - Scopes + - [x] `openid email profile address`` + - [x] `offline access` + - [x] `urn:zitadel:iam:org:idp:id:{idp_id}` + - [x] `urn:zitadel:iam:org:project:id:zitadel:aud` + - [x] `urn:zitadel:iam:org:id:{orgid}` + - [x] `urn:zitadel:iam:org:domain:primary:{domain}` + - [ ] AuthRequest UI locales + + #### Flow diagram + + This diagram shows the available pages and flows. + + > Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +You can find a more detailed documentation of the different pages [here](./apps/login/readme.md). + +## Tooling + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Prettier](https://prettier.io) for code formatting + +## Useful Commands + +- `pnpm generate` - Build proto stubs for server and client package +- `pnpm build` - Build all packages and the login app +- `pnpm test` - Test all packages and the login app +- `pnpm test:watch` - Rerun tests on file change +- `pnpm dev` - Develop all packages and the login app +- `pnpm lint` - Lint all packages +- `pnpm changeset` - Generate a changeset +- `pnpm clean` - Clean up all `node_modules` and `dist` folders (runs each package's clean script) + +## Versioning And Publishing Packages + +Package publishing has been configured using [Changesets](https://github.com/changesets/changesets). +Here is their [documentation](https://github.com/changesets/changesets#documentation) for more information about the +workflow. + +The [GitHub Action](https://github.com/changesets/action) needs an `NPM_TOKEN` and `GITHUB_TOKEN` in the repository +settings. The [Changesets bot](https://github.com/apps/changeset-bot) should also be installed on the GitHub repository. + +Read the [changesets documentation](https://github.com/changesets/changesets/blob/main/docs/automating-changesets.md) +for more information about this automation + +### Run Login UI + +To run the application make sure to install the dependencies with + +```sh +pnpm install +``` + +then generate the GRPC stubs with + +```sh +pnpm generate +``` + +To run the application against a local ZITADEL instance, run the following command: + +```sh +pnpm run-zitadel +``` + +This sets up ZITADEL using docker compose and writes the configuration to the file `apps/login/.env.local`. + +
+Alternatively, use another environment +You can develop against any ZITADEL instance in which you have sufficient rights to execute the following steps. +Just create or overwrite the file `apps/login/.env.local` yourself. +Add your instances base URL to the file at the key `ZITADEL_API_URL`. +Go to your instance and create a service user for the login application. +The login application creates users on your primary organization and reads policy data. +For the sake of simplicity, just make the service user an instance member with the role `IAM_OWNER`. +Create a PAT and copy it to the file `apps/login/.env.local` using the key `ZITADEL_SERVICE_USER_TOKEN`. + +The file should look similar to this: + +``` +ZITADEL_API_URL=https://zitadel-tlx3du.us1.zitadel.cloud +ZITADEL_SERVICE_USER_TOKEN=1S6w48thfWFI2klgfwkCnhXJLf9FQ457E-_3H74ePQxfO3Af0Tm4V5Xi-ji7urIl_xbn-Rk +``` + +
+ +Start the login application in dev mode: + +```sh +pnpm dev +``` + +Open the login application with your favorite browser at `localhost:3000`. +Change the source code and see the changes live in your browser. + +Make sure the application still behaves as expected by running all tests + +```sh +pnpm test +``` + +To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts. + +### Run Login UI Acceptance tests + +To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's. +This component should also be able to return the content of these notifications, as the codes and links are used in the login flows. +There is a basic implementation in Golang available under [the sink package](./acceptance/sink). + +To setup ZITADEL with the additional Sink container for handling the notifications: + +```sh +pnpm run-sink +``` + +Then you can start the acceptance tests with: + +```sh +pnpm test:acceptance +``` + +### Deploy to Vercel + +To deploy your own version on Vercel, navigate to your instance and create a service user. +Then create a personal access token (PAT), copy and set it as ZITADEL_SERVICE_USER_TOKEN, then navigate to your instance +settings and make sure it gets IAM_OWNER permissions. +Finally set your instance url as ZITADEL_API_URL. Make sure to set it without trailing slash. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_LOGIN_CLIENT%20membership%20on%20your%20instance%20and%20provide%20its%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) diff --git a/login/acceptance/Dockerfile b/login/acceptance/Dockerfile new file mode 100644 index 0000000000..dd29721bc3 --- /dev/null +++ b/login/acceptance/Dockerfile @@ -0,0 +1,5 @@ +FROM golang:1.24-alpine +RUN apk add curl jq +COPY setup.sh /setup.sh +RUN chmod +x /setup.sh +ENTRYPOINT [ "/setup.sh" ] diff --git a/login/acceptance/docker-compose.yaml b/login/acceptance/docker-compose.yaml new file mode 100644 index 0000000000..d033b1c39c --- /dev/null +++ b/login/acceptance/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + zitadel: + user: "${ZITADEL_DEV_UID}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + extra_hosts: + - "localhost:host-gateway" + + db: + restart: "always" + image: postgres:17.0-alpine3.19 + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - 5432:5432 + + wait_for_zitadel: + image: curlimages/curl:8.00.1 + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + setup: + user: "${ZITADEL_DEV_UID}" + container_name: setup + build: . + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /apps/login/.env.local + WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + volumes: + - "./pat:/pat" + - "../apps/login:/apps/login" + - "../acceptance/tests:/acceptance/tests" + depends_on: + wait_for_zitadel: + condition: "service_completed_successfully" + + sink: + image: golang:1.24-alpine + container_name: sink + command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification' + ports: + - 3333:3333 + volumes: + - "./sink:/sink" + depends_on: + setup: + condition: "service_completed_successfully" diff --git a/login/acceptance/idp/oidc/docker-compose.yaml b/login/acceptance/idp/oidc/docker-compose.yaml new file mode 100644 index 0000000000..3aeced18a8 --- /dev/null +++ b/login/acceptance/idp/oidc/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + oidcop: + image: golang:1.24-alpine + container_name: oidcop + command: go run main.go + environment: + API_URL: 'http://localhost:8080' + API_DOMAIN: 'localhost:8080' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'http' + HOST: 'localhost' + PORT: "8004" + working_dir: /oidc + ports: + - 8004:8004 + volumes: + - "../../pat:/pat" + - "./:/oidc" + extra_hosts: + - "localhost:host-gateway" diff --git a/login/acceptance/idp/oidc/go.mod b/login/acceptance/idp/oidc/go.mod new file mode 100644 index 0000000000..84dae766c8 --- /dev/null +++ b/login/acceptance/idp/oidc/go.mod @@ -0,0 +1,28 @@ +module github.com/zitadel/typescript/acceptance/idp/oidc + +go 1.24.1 + +require github.com/zitadel/oidc/v3 v3.37.0 + +require ( + github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/zitadel/logging v0.6.2 // indirect + github.com/zitadel/schema v1.3.1 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/login/acceptance/idp/oidc/go.sum b/login/acceptance/idp/oidc/go.sum new file mode 100644 index 0000000000..42d80d8683 --- /dev/null +++ b/login/acceptance/idp/oidc/go.sum @@ -0,0 +1,71 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= +github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/acceptance/idp/oidc/main.go b/login/acceptance/idp/oidc/main.go new file mode 100644 index 0000000000..b04ac94234 --- /dev/null +++ b/login/acceptance/idp/oidc/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/zitadel/oidc/v3/example/server/exampleop" + "github.com/zitadel/oidc/v3/example/server/storage" +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + + issuer := fmt.Sprintf("%s://%s:%s/", schema, host, port) + redirectURI := fmt.Sprintf("%s/idps/callback", apiURL) + + clientID := "web" + clientSecret := "secret" + storage.RegisterClients( + storage.WebClient(clientID, clientSecret, redirectURI), + ) + + storage := storage.NewStorage(storage.NewUserStore(issuer)) + router := exampleop.SetupServer(issuer, storage, logger, false) + + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret string) error { + idpID, err := CreateIDP(apiURL, pat, domain, issuer, clientID, clientSecret) + if err != nil { + return err + } + return ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes"` + ProviderOptions providerOptions `json:"providerOptions"` + IsIdTokenMapping bool `json:"isIdTokenMapping"` + UsePkce bool `json:"usePkce"` +} + +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, issuer, clientID, clientSecret string) (string, error) { + createIDP := &createIDP{ + Name: "OIDC", + Issuer: issuer, + ClientId: clientID, + ClientSecret: clientSecret, + Scopes: []string{"openid", "profile", "email"}, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + IsIdTokenMapping: false, + UsePkce: false, + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/generic_oidc", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/acceptance/idp/saml/docker-compose.yaml b/login/acceptance/idp/saml/docker-compose.yaml new file mode 100644 index 0000000000..30e5a26fc3 --- /dev/null +++ b/login/acceptance/idp/saml/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + samlidp: + image: golang:1.24-alpine + container_name: samlidp + command: go run main.go + environment: + API_URL: 'http://localhost:8080' + API_DOMAIN: 'localhost:8080' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'http' + HOST: 'localhost' + PORT: "8003" + working_dir: /saml + ports: + - 8003:8003 + volumes: + - "../../pat:/pat" + - "./:/saml" + extra_hosts: + - "localhost:host-gateway" diff --git a/login/acceptance/idp/saml/go.mod b/login/acceptance/idp/saml/go.mod new file mode 100644 index 0000000000..e73b4feb3b --- /dev/null +++ b/login/acceptance/idp/saml/go.mod @@ -0,0 +1,16 @@ +module github.com/zitadel/typescript/acceptance/idp/saml + +go 1.24.1 + +require ( + github.com/crewjam/saml v0.4.14 + github.com/mattermost/xml-roundtrip-validator v0.1.0 + github.com/zenazn/goji v1.0.1 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/beevik/etree v1.1.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect +) diff --git a/login/acceptance/idp/saml/go.sum b/login/acceptance/idp/saml/go.sum new file mode 100644 index 0000000000..1208550f6e --- /dev/null +++ b/login/acceptance/idp/saml/go.sum @@ -0,0 +1,49 @@ +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/acceptance/idp/saml/main.go b/login/acceptance/idp/saml/main.go new file mode 100644 index 0000000000..059eab79e2 --- /dev/null +++ b/login/acceptance/idp/saml/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/logger" + "github.com/crewjam/saml/samlidp" + xrv "github.com/mattermost/xml-roundtrip-validator" + "github.com/zenazn/goji" + "github.com/zenazn/goji/bind" + "github.com/zenazn/goji/web" + "golang.org/x/crypto/bcrypt" +) + +var key = func() crypto.PrivateKey { + b, _ := pem.Decode([]byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0OhbMuizgtbFOfwbK7aURuXhZx6VRuAs3nNibiuifwCGz6u9 +yy7bOR0P+zqN0YkjxaokqFgra7rXKCdeABmoLqCC0U+cGmLNwPOOA0PaD5q5xKhQ +4Me3rt/R9C4Ca6k3/OnkxnKwnogcsmdgs2l8liT3qVHP04Oc7Uymq2v09bGb6nPu +fOrkXS9F6mSClxHG/q59AGOWsXK1xzIRV1eu8W2SNdyeFVU1JHiQe444xLoPul5t +InWasKayFsPlJfWNc8EoU8COjNhfo/GovFTHVjh9oUR/gwEFVwifIHihRE0Hazn2 +EQSLaOr2LM0TsRsQroFjmwSGgI+X2bfbMTqWOQIDAQABAoIBAFWZwDTeESBdrLcT +zHZe++cJLxE4AObn2LrWANEv5AeySYsyzjRBYObIN9IzrgTb8uJ900N/zVr5VkxH +xUa5PKbOcowd2NMfBTw5EEnaNbILLm+coHdanrNzVu59I9TFpAFoPavrNt/e2hNo +NMGPSdOkFi81LLl4xoadz/WR6O/7N2famM+0u7C2uBe+TrVwHyuqboYoidJDhO8M +w4WlY9QgAUhkPyzZqrl+VfF1aDTGVf4LJgaVevfFCas8Ws6DQX5q4QdIoV6/0vXi +B1M+aTnWjHuiIzjBMWhcYW2+I5zfwNWRXaxdlrYXRukGSdnyO+DH/FhHePJgmlkj +NInADDkCgYEA6MEQFOFSCc/ELXYWgStsrtIlJUcsLdLBsy1ocyQa2lkVUw58TouW +RciE6TjW9rp31pfQUnO2l6zOUC6LT9Jvlb9PSsyW+rvjtKB5PjJI6W0hjX41wEO6 +fshFELMJd9W+Ezao2AsP2hZJ8McCF8no9e00+G4xTAyxHsNI2AFTCQcCgYEA5cWZ +JwNb4t7YeEajPt9xuYNUOQpjvQn1aGOV7KcwTx5ELP/Hzi723BxHs7GSdrLkkDmi +Gpb+mfL4wxCt0fK0i8GFQsRn5eusyq9hLqP/bmjpHoXe/1uajFbE1fZQR+2LX05N +3ATlKaH2hdfCJedFa4wf43+cl6Yhp6ZA0Yet1r8CgYEAwiu1j8W9G+RRA5/8/DtO +yrUTOfsbFws4fpLGDTA0mq0whf6Soy/96C90+d9qLaC3srUpnG9eB0CpSOjbXXbv +kdxseLkexwOR3bD2FHX8r4dUM2bzznZyEaxfOaQypN8SV5ME3l60Fbr8ajqLO288 +wlTmGM5Mn+YCqOg/T7wjGmcCgYBpzNfdl/VafOROVbBbhgXWtzsz3K3aYNiIjbp+ +MunStIwN8GUvcn6nEbqOaoiXcX4/TtpuxfJMLw4OvAJdtxUdeSmEee2heCijV6g3 +ErrOOy6EqH3rNWHvlxChuP50cFQJuYOueO6QggyCyruSOnDDuc0BM0SGq6+5g5s7 +H++S/wKBgQDIkqBtFr9UEf8d6JpkxS0RXDlhSMjkXmkQeKGFzdoJcYVFIwq8jTNB +nJrVIGs3GcBkqGic+i7rTO1YPkquv4dUuiIn+vKZVoO6b54f+oPBXd4S0BnuEqFE +rdKNuCZhiaE2XD9L/O9KP1fh5bfEcKwazQ23EvpJHBMm8BGC+/YZNw== +-----END RSA PRIVATE KEY-----`)) + k, _ := x509.ParsePKCS1PrivateKey(b.Bytes) + return k +}() + +var cert = func() *x509.Certificate { + b, _ := pem.Decode([]byte(`-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5 +NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8A +hs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+a +ucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWx +m+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6 +D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURN +B2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0O +BBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56 +zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5 +pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uv +NONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEf +y/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL +/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsb +GFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTL +UzreO96WzlBBMtY= +-----END CERTIFICATE-----`)) + c, _ := x509.ParseCertificate(b.Bytes) + return c +}() + +// Example from https://github.com/crewjam/saml/blob/main/example/idp/idp.go +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + baseURL, err := url.Parse(schema + "://" + host + ":" + port) + if err != nil { + + panic(err) + } + + idpServer, err := samlidp.New(samlidp.Options{ + URL: *baseURL, + Logger: logger.DefaultLogger, + Key: key, + Certificate: cert, + Store: &samlidp.MemoryStore{}, + }) + if err != nil { + + panic(err) + } + + metadata, err := xml.MarshalIndent(idpServer.IDP.Metadata(), "", " ") + if err != nil { + panic(err) + } + idpID, err := createZitadelResources(apiURL, pat, domain, metadata) + if err != nil { + panic(err) + } + + lis := bind.Socket(":" + baseURL.Port()) + goji.Handle("/*", idpServer) + + go func() { + goji.ServeListener(lis) + }() + + addService(idpServer, apiURL+"/idps/"+idpID+"/saml/metadata") + addUsers(idpServer) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + if err := lis.Close(); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func addService(idpServer *samlidp.Server, spURLStr string) { + metadataResp, err := http.Get(spURLStr) + if err != nil { + panic(err) + } + defer metadataResp.Body.Close() + + idpServer.HandlePutService( + web.C{URLParams: map[string]string{"id": spURLStr}}, + httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, spURLStr, metadataResp.Body), + ) +} + +func getSPMetadata(r io.Reader) (spMetadata *saml.EntityDescriptor, err error) { + var data []byte + if data, err = io.ReadAll(r); err != nil { + return nil, err + } + + spMetadata = &saml.EntityDescriptor{} + if err := xrv.Validate(bytes.NewBuffer(data)); err != nil { + return nil, err + } + + if err := xml.Unmarshal(data, &spMetadata); err != nil { + if err.Error() == "expected element type but have " { + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, &entities); err != nil { + return nil, err + } + + for _, e := range entities.EntityDescriptors { + if len(e.SPSSODescriptors) > 0 { + return &e, nil + } + } + + // there were no SPSSODescriptors in the response + return nil, errors.New("metadata contained no service provider metadata") + } + + return nil, err + } + + return spMetadata, nil +} + +func addUsers(idpServer *samlidp.Server) { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.DefaultCost) + err := idpServer.Store.Put("/users/alice", samlidp.User{Name: "alice", + HashedPassword: hashedPassword, + Groups: []string{"Administrators", "Users"}, + Email: "alice@example.com", + CommonName: "Alice Smith", + Surname: "Smith", + GivenName: "Alice", + }) + if err != nil { + panic(err) + } + + err = idpServer.Store.Put("/users/bob", samlidp.User{ + Name: "bob", + HashedPassword: hashedPassword, + Groups: []string{"Users"}, + Email: "bob@example.com", + CommonName: "Bob Smith", + Surname: "Smith", + GivenName: "Bob", + }) + if err != nil { + panic(err) + } +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte) (string, error) { + idpID, err := CreateIDP(apiURL, pat, domain, metadata) + if err != nil { + return "", err + } + return idpID, ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + Binding string `json:"binding"` + WithSignedRequest bool `json:"withSignedRequest"` + ProviderOptions providerOptions `json:"providerOptions"` + NameIdFormat string `json:"nameIdFormat"` +} +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, idpMetadata []byte) (string, error) { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(idpMetadata))) + base64.URLEncoding.Encode(encoded, idpMetadata) + + createIDP := &createIDP{ + Name: "CREWJAM", + MetadataXml: string(encoded), + Binding: "SAML_BINDING_REDIRECT", + WithSignedRequest: false, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + NameIdFormat: "SAML_NAME_ID_FORMAT_PERSISTENT", + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/saml", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/acceptance/oidc/docker-compose.yaml b/login/acceptance/oidc/docker-compose.yaml new file mode 100644 index 0000000000..88f023503c --- /dev/null +++ b/login/acceptance/oidc/docker-compose.yaml @@ -0,0 +1,22 @@ +services: + oidcrp: + image: golang:1.24-alpine + container_name: oidcrp + command: go run main.go + environment: + API_URL: 'http://localhost:8080' + API_DOMAIN: 'localhost:8080' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'http://localhost:3000' + ISSUER: 'http://localhost:3000' + HOST: 'http://localhost' + PORT: '8000' + SCOPES: 'openid profile email' + working_dir: /oidc + ports: + - 8000:8000 + volumes: + - "../pat:/pat" + - "./:/oidc" + extra_hosts: + - "localhost:host-gateway" diff --git a/login/acceptance/oidc/go.mod b/login/acceptance/oidc/go.mod new file mode 100644 index 0000000000..f2cda3058e --- /dev/null +++ b/login/acceptance/oidc/go.mod @@ -0,0 +1,26 @@ +module github.com/zitadel/typescript/acceptance/oidc + +go 1.24.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/sirupsen/logrus v1.9.3 + github.com/zitadel/logging v0.6.1 + github.com/zitadel/oidc/v3 v3.36.1 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/login/acceptance/oidc/go.sum b/login/acceptance/oidc/go.sum new file mode 100644 index 0000000000..33244ea6eb --- /dev/null +++ b/login/acceptance/oidc/go.sum @@ -0,0 +1,67 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= +github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= +github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/acceptance/oidc/main.go b/login/acceptance/oidc/main.go new file mode 100644 index 0000000000..ac3242c132 --- /dev/null +++ b/login/acceptance/oidc/main.go @@ -0,0 +1,313 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var ( + callbackPath = "/auth/callback" + key = []byte("test1234test1234") +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + issuer := os.Getenv("ISSUER") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + scopeList := strings.Split(os.Getenv("SCOPES"), " ") + + redirectURI := fmt.Sprintf("%v:%v%v", host, port, callbackPath) + cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) + + clientID, clientSecret, err := createZitadelResources(apiURL, pat, domain, redirectURI, loginURL) + if err != nil { + panic(err) + } + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + client := &http.Client{ + Timeout: time.Minute, + } + // enable outgoing request logging + logging.EnableHTTPClient(client, + logging.WithClientGroup("client"), + ) + + options := []rp.Option{ + rp.WithCookieHandler(cookieHandler), + rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)), + rp.WithHTTPClient(client), + rp.WithLogger(logger), + rp.WithSigningAlgsFromDiscovery(), + } + if clientSecret == "" { + options = append(options, rp.WithPKCE(cookieHandler)) + } + + // One can add a logger to the context, + // pre-defining log attributes as required. + ctx := logging.ToContext(context.TODO(), logger) + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopeList, options...) + if err != nil { + logrus.Fatalf("error creating provider %s", err.Error()) + } + + // generate some state (representing the state of the user in your application, + // e.g. the page where he was before sending him to login + state := func() string { + return uuid.New().String() + } + + urlOptions := []rp.URLParamOpt{ + rp.WithPromptURLParam("Welcome back!"), + } + + // register the AuthURLHandler at your preferred path. + // the AuthURLHandler creates the auth request and redirects the user to the auth server. + // including state handling with secure cookie and the possibility to use PKCE. + // Prompts can optionally be set to inform the server of + // any messages that need to be prompted back to the user. + http.Handle("/login", rp.AuthURLHandler( + state, + provider, + urlOptions..., + )) + + // for demonstration purposes the returned userinfo response is written as JSON object onto response + marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { + fmt.Println("access token", tokens.AccessToken) + fmt.Println("refresh token", tokens.RefreshToken) + fmt.Println("id token", tokens.IDToken) + + data, err := json.Marshal(info) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("content-type", "application/json") + w.Write(data) + } + + // register the CodeExchangeHandler at the callbackPath + // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function + // with the returned tokens from the token endpoint + // in this example the callback function itself is wrapped by the UserinfoCallback which + // will call the Userinfo endpoint, check the sub and pass the info into the callback function + http.Handle(callbackPath, rp.CodeExchangeHandler(rp.UserinfoCallback(marshalUserinfo), provider)) + + // if you would use the callback without calling the userinfo endpoint, simply switch the callback handler for: + // + // http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider)) + + // simple counter for request IDs + var counter atomic.Int64 + // enable incomming request logging + mw := logging.Middleware( + logging.WithLogger(logger), + logging.WithGroup("server"), + logging.WithIDFunc(func() slog.Attr { + return slog.Int64("id", counter.Add(1)) + }), + ) + + server := &http.Server{ + Addr: ":" + port, + Handler: mw(http.DefaultServeMux), + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, redirectURI, loginURL string) (string, string, error) { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return "", "", err + } + return CreateApp(apiURL, pat, domain, projectID, redirectURI, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "OIDC", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + fmt.Printf("projectID: %+v\n", p.ID) + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + RedirectUris []string `json:"redirectUris"` + ResponseTypes []string `json:"responseTypes"` + GrantTypes []string `json:"grantTypes"` + AppType string `json:"appType"` + AuthMethodType string `json:"authMethodType"` + PostLogoutRedirectUris []string `json:"postLogoutRedirectUris"` + Version string `json:"version"` + DevMode bool `json:"devMode"` + AccessTokenType string `json:"accessTokenType"` + AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion"` + IdTokenRoleAssertion bool `json:"idTokenRoleAssertion"` + IdTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion"` + ClockSkew string `json:"clockSkew"` + AdditionalOrigins []string `json:"additionalOrigins"` + SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage"` + BackChannelLogoutUri []string `json:"backChannelLogoutUri"` + LoginVersion version `json:"loginVersion"` +} + +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +type app struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +func CreateApp(apiURL, pat, domain, projectID string, redirectURI, loginURL string) (string, string, error) { + createApp := &createApp{ + Name: "OIDC", + RedirectUris: []string{redirectURI}, + ResponseTypes: []string{"OIDC_RESPONSE_TYPE_CODE"}, + GrantTypes: []string{"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"}, + AppType: "OIDC_APP_TYPE_WEB", + AuthMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + Version: "OIDC_VERSION_1_0", + DevMode: true, + AccessTokenType: "OIDC_TOKEN_TYPE_BEARER", + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: "1s", + SkipNativeAppSuccessPage: true, + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/oidc", pat, domain, createApp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + a := new(app) + if err := json.Unmarshal(data, a); err != nil { + return "", "", err + } + return a.ClientID, a.ClientSecret, err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/acceptance/pat/.gitignore b/login/acceptance/pat/.gitignore new file mode 100644 index 0000000000..f0fa09f556 --- /dev/null +++ b/login/acceptance/pat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep \ No newline at end of file diff --git a/login/acceptance/pat/.gitkeep b/login/acceptance/pat/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/acceptance/saml/docker-compose.yaml b/login/acceptance/saml/docker-compose.yaml new file mode 100644 index 0000000000..c2301bac69 --- /dev/null +++ b/login/acceptance/saml/docker-compose.yaml @@ -0,0 +1,22 @@ +services: + samlsp: + image: golang:1.24-alpine + container_name: samlsp + command: go run main.go + environment: + API_URL: 'http://localhost:8080' + API_DOMAIN: 'localhost:8080' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'http://localhost:3000' + IDP_URL: 'http://localhost:3000/saml/v2/metadata' + HOST: 'http://localhost' + PORT: '8001' + working_dir: /saml + ports: + - 8001:8001 + volumes: + - "../pat:/pat" + - "./:/saml" + extra_hosts: + - "localhost:host-gateway" + diff --git a/login/acceptance/saml/go.mod b/login/acceptance/saml/go.mod new file mode 100644 index 0000000000..9986149bfb --- /dev/null +++ b/login/acceptance/saml/go.mod @@ -0,0 +1,18 @@ +module github.com/zitadel/typescript/acceptance/saml + +go 1.24.0 + +require github.com/crewjam/saml v0.4.14 + +require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/httperr v0.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.5.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/crypto v0.36.0 // indirect +) diff --git a/login/acceptance/saml/go.sum b/login/acceptance/saml/go.sum new file mode 100644 index 0000000000..3394a39410 --- /dev/null +++ b/login/acceptance/saml/go.sum @@ -0,0 +1,38 @@ +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= +github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/acceptance/saml/main.go b/login/acceptance/saml/main.go new file mode 100644 index 0000000000..0886fa5613 --- /dev/null +++ b/login/acceptance/saml/main.go @@ -0,0 +1,266 @@ +package main + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/crewjam/saml/samlsp" +) + +var keyPair = func() tls.Certificate { + cert := []byte(`-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUKjAUmxsHO44X+/TKBNciPgNl1GEwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIxOTEz +Mzc1MVoXDTI1MTIxOTEzMzc1MVowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0QYuJsayILRI +hVT7G1DlitVSXnt1iw3gEXJZfe81Egz06fUbvXF6Yo1LJmwYpqe/rm+hf4FNUb8e +2O+LH2FieA9FkVe4P2gKOzw87A/KxvpV8stgNgl4LlqRCokbc1AzeE/NiLr5TcTD +RXm3DUcYxXxinprtDu2jftFysaOZmNAukvE/iL6qS3X6ggVEDDM7tY9n5FV2eJ4E +p0ImKfypi2aZYROxOK+v5x9ryFRMl4y07lMDvmtcV45uXYmfGNCgG9PNf91Kk/mh +JxEQbxycJwFoSi9XWljR8ahPdO11LXG7Dsj/RVbY8k2LdKNstl6Ae3aCpbe9u2Pj +vxYs1bVJuQIDAQABo1MwUTAdBgNVHQ4EFgQU+mRVN5HYJWgnpopReaLhf2cMcoYw +HwYDVR0jBBgwFoAU+mRVN5HYJWgnpopReaLhf2cMcoYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEABJpHVuc9tGhD04infRVlofvqXIUizTlOrjZX +vozW9pIhSWEHX8o+sJP8AMZLnrsdq+bm0HE0HvgYrw7Lb8pd4FpR46TkFHjeukoj +izqfgckjIBl2nwPGlynbKA0/U/rTCSxVt7XiAn+lgYUGIpOzNdk06/hRMitrMNB7 +t2C97NseVC4b1ZgyFrozsefCfUmD8IJF0+XJ4Wzmsh0jRrI8koCtVmPYnKn6vw1b +cZprg/97CWHYrsavd406wOB60CMtYl83Q16ucOF1dretDFqJC5kY+aFLvuqfag2+ +kIaoPV1MnGsxveQyyHdOsEatS5XOv/1OWcmnvePDPxcvb9jCcw== +-----END CERTIFICATE----- +`) + key := []byte(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRBi4mxrIgtEiF +VPsbUOWK1VJee3WLDeARcll97zUSDPTp9Ru9cXpijUsmbBimp7+ub6F/gU1Rvx7Y +74sfYWJ4D0WRV7g/aAo7PDzsD8rG+lXyy2A2CXguWpEKiRtzUDN4T82IuvlNxMNF +ebcNRxjFfGKemu0O7aN+0XKxo5mY0C6S8T+IvqpLdfqCBUQMMzu1j2fkVXZ4ngSn +QiYp/KmLZplhE7E4r6/nH2vIVEyXjLTuUwO+a1xXjm5diZ8Y0KAb081/3UqT+aEn +ERBvHJwnAWhKL1daWNHxqE907XUtcbsOyP9FVtjyTYt0o2y2XoB7doKlt727Y+O/ +FizVtUm5AgMBAAECggEACak+l5f6Onj+u5vrjc4JyAaXW6ra6loSM9g8Uu3sHukW +plwoA7Pzp0u20CAxrP1Gpqw984/hSCCcb0Q2ItWMWLaC/YZni5W2WFnOyo3pzlPa +hmH4UNMT+ReCSfF/oW8w69QLcNEMjhfEu0i2iWBygIlA4SoRwC2Db6yEX7nLMwUB +6AICid9hfeACNRz/nq5ytdcHdmcB7Ptgb9jLiXr6RZw26g5AsRPHU3LdcyZAOXjP +aUHriHuHQFKAVkoEUxslvCB6ePCTCpB0bSAuzQbeGoY8fmvmNSCvJ1vrH5hiSUYp +Axtl5iNgFl5o9obb0eBYlY9x3pMSz0twdbCwfR7HAQKBgQDtWhmFm0NaJALoY+tq +lIIC0EOMSrcRIlgeXr6+g8womuDOMi5m/Nr5Mqt4mPOdP4HytrQb+a/ZmEm17KHh +mQb1vwH8ffirCBHbPNC1vwSNoxDKv9E6OysWlKiOzxPFSVZr3dKl2EMX6qi17n0l +LBrGXXaNPgYiHSmwBA5CZvvouQKBgQDhclGJfZfuoubQkUuz8yOA2uxalh/iUmQ/ +G8ac6/w7dmnL9pXehqCWh06SeC3ZvW7yrf7IIGx4sTJji2FzQ+8Ta6pPELMyBEXr +1VirIFrlNVMlMQEbZcbzdzEhchM1RUpZJtl3b4amvH21UcRB69d9klcDRisKoFRm +k0P9QLHpAQKBgQDh5J9nphZa4u0ViYtTW1XFIbs3+R/0IbCl7tww67TRbF3KQL4i +7EHna88ALumkXf3qJvKRsXgoaqS0jSqgUAjst8ZHLQkOldaQxneIkezedDSWEisp +9YgTrJYjnHefiyXB8VL63jE0wPOiewEF8Mzmv6sFz+L8cq7rQ2Di16qmmQKBgQDH +bvCwVxkrMpJK2O2GH8U9fOzu6bUE6eviY/jb4mp8U7EdjGJhuuieoM2iBoxQ/SID +rmYftYcfcWlo4+juJZ99p5W+YcCTs3IDQPUyVOnzr6uA0Avxp6RKxhsBQj+5tTUj +Dpn77P3JzB7MYqvhwPcdD3LH46+5s8FWCFpx02RPAQKBgARbngtggfifatcsMC7n +lSv/FVLH7LYQAHdoW/EH5Be7FeeP+eQvGXwh1dgl+u0VZO8FvI8RwFganpBRR2Nc +ZSBRIb0fSUlTvIsckSWjpEvUJUomJXyi4PIZAfNvd9/u1uLInQiCDtObwb6hnLTU +FHHEZ+dR4eMaJp6PhNm8hu2O +-----END PRIVATE KEY----- +`) + + kp, err := tls.X509KeyPair(cert, key) + if err != nil { + panic(err) + } + kp.Leaf, err = x509.ParseCertificate(kp.Certificate[0]) + if err != nil { + panic(err) + } + return kp +}() + +func hello(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", samlsp.AttributeFromContext(r.Context(), "UserName")) +} + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + idpURL := os.Getenv("IDP_URL") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + idpMetadataURL, err := url.Parse(idpURL) + if err != nil { + panic(err) + } + idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, + *idpMetadataURL) + if err != nil { + panic(err) + } + fmt.Printf("idpMetadata: %+v\n", idpMetadata) + rootURL, err := url.Parse(host + ":" + port) + if err != nil { + panic(err) + } + + samlSP, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + }) + if err != nil { + panic(err) + } + + server := &http.Server{ + Addr: ":" + port, + } + app := http.HandlerFunc(hello) + http.Handle("/hello", samlSP.RequireAccount(app)) + http.Handle("/saml/", samlSP) + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + metadata, err := xml.MarshalIndent(samlSP.ServiceProvider.Metadata(), "", " ") + if err != nil { + panic(err) + } + if err := createZitadelResources(apiURL, pat, domain, metadata, loginURL); err != nil { + panic(err) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte, loginURL string) error { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return err + } + return CreateApp(apiURL, pat, domain, projectID, metadata, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "SAML", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + LoginVersion version `json:"loginVersion"` +} +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +func CreateApp(apiURL, pat, domain, projectID string, spMetadata []byte, loginURL string) error { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(spMetadata))) + base64.URLEncoding.Encode(encoded, spMetadata) + + createApp := &createApp{ + Name: "SAML", + MetadataXml: string(encoded), + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + + _, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/saml", pat, domain, createApp) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/acceptance/setup.sh b/login/acceptance/setup.sh new file mode 100755 index 0000000000..cdb04043e0 --- /dev/null +++ b/login/acceptance/setup.sh @@ -0,0 +1,137 @@ +#!/bin/sh + +set -ex + +PAT_FILE=${PAT_FILE:-./pat/zitadel-admin-sa.pat} +ZITADEL_API_PROTOCOL="${ZITADEL_API_PROTOCOL:-http}" +ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}" +ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}" +ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}" +ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}" +SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}" +SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}" +SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}" + +if [ -z "${PAT}" ]; then + echo "Reading PAT from file ${PAT_FILE}" + PAT=$(cat ${PAT_FILE}) +fi + +################################################################# +# ServiceAccount as Login Client +################################################################# + +SERVICEACCOUNT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/machine" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userName\": \"login\", \"name\": \"Login v2\", \"description\": \"Serviceaccount for Login v2\", \"accessTokenType\": \"ACCESS_TOKEN_TYPE_BEARER\"}") +echo "Received ServiceAccount response: ${SERVICEACCOUNT_RESPONSE}" + +SERVICEACCOUNT_ID=$(echo ${SERVICEACCOUNT_RESPONSE} | jq -r '. | .userId') +echo "Received ServiceAccount ID: ${SERVICEACCOUNT_ID}" + +MEMBER_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/members" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userId\": \"${SERVICEACCOUNT_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/${SERVICEACCOUNT_ID}/pats" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"expirationDate\": \"2519-04-01T08:45:00.000000Z\"}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT=$(echo ${SA_PAT_RESPONSE} | jq -r '. | .token') +echo "Received ServiceAccount Token: ${SA_PAT}" + +################################################################# +# Environment files +################################################################# + +WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local} +echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done." +WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local} +echo "Writing environment file to ${WRITE_TEST_ENVIRONMENT_FILE} when done." + +echo "ZITADEL_API_URL=${ZITADEL_API_URL} +ZITADEL_SERVICE_USER_TOKEN=${SA_PAT} +ZITADEL_ADMIN_TOKEN=${PAT} +SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} +EMAIL_VERIFICATION=true +DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null +echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" +cat ${WRITE_ENVIRONMENT_FILE} + +echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}" +cat ${WRITE_TEST_ENVIRONMENT_FILE} + +################################################################# +# SMS provider with HTTP +################################################################# + +SMSHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}" + +SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id') +echo "Received SMS HTTP ID: ${SMSHTTP_ID}" + +SMS_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}" + +################################################################# +# Email provider with HTTP +################################################################# + +EMAILHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}" + +EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id') +echo "Received Email HTTP ID: ${EMAILHTTP_ID}" + +EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}" + +################################################################# +# Wait for projection of default organization in ZITADEL +################################################################# + +DEFAULTORG_RESPONSE_RESULTS=0 +# waiting for default organization +until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] +do + DEFAULTORG_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"queries\": [{\"defaultQuery\":{}}]}" ) + echo "Received default organization response: ${DEFAULTORG_RESPONSE}" + DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') + echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" +done + diff --git a/login/acceptance/sink/go.mod b/login/acceptance/sink/go.mod new file mode 100644 index 0000000000..1da7622b58 --- /dev/null +++ b/login/acceptance/sink/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/acceptance/sink + +go 1.24.0 diff --git a/login/acceptance/sink/main.go b/login/acceptance/sink/main.go new file mode 100644 index 0000000000..d591981a34 --- /dev/null +++ b/login/acceptance/sink/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" +) + +type serializableData struct { + ContextInfo map[string]interface{} `json:"contextInfo,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` +} + +type response struct { + Recipient string `json:"recipient,omitempty"` +} + +func main() { + port := flag.String("port", "3333", "used port for the sink") + email := flag.String("email", "/email", "path for a sent email") + emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification") + sms := flag.String("sms", "/sms", "path for a sent sms") + smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification") + notification := flag.String("notification", "/notification", "path to receive the notification") + flag.Parse() + + messages := make(map[string]serializableData) + + http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email, ok := serializableData.ContextInfo[*emailKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(email + ": " + string(data)) + messages[email] = serializableData + io.WriteString(w, "Email!\n") + }) + + http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + phone, ok := serializableData.ContextInfo[*smsKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(phone + ": " + string(data)) + messages[phone] = serializableData + io.WriteString(w, "SMS!\n") + }) + + http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + response := response{} + if err := json.Unmarshal(data, &response); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData, err := json.Marshal(messages[response.Recipient]) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + io.WriteString(w, string(serializableData)) + }) + + fmt.Println("Starting server on", *port) + fmt.Println(*email, " for email handling") + fmt.Println(*sms, " for sms handling") + fmt.Println(*notification, " for retrieving notifications") + err := http.ListenAndServe(":"+*port, nil) + if err != nil { + panic("Server could not be started: " + err.Error()) + } +} diff --git a/login/acceptance/tests/admin.spec.ts b/login/acceptance/tests/admin.spec.ts new file mode 100644 index 0000000000..7ca28e4419 --- /dev/null +++ b/login/acceptance/tests/admin.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { loginScreenExpect, loginWithPassword } from "./login"; + +test("admin login", async ({ page }) => { + await loginWithPassword(page, "zitadel-admin@zitadel.localhost", "Password1!"); + await loginScreenExpect(page, "ZITADEL Admin"); +}); diff --git a/login/acceptance/tests/code-screen.ts b/login/acceptance/tests/code-screen.ts new file mode 100644 index 0000000000..3ab9dad26d --- /dev/null +++ b/login/acceptance/tests/code-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function codeScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function codeScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code"); +} diff --git a/login/acceptance/tests/code.ts b/login/acceptance/tests/code.ts new file mode 100644 index 0000000000..1ae8f69791 --- /dev/null +++ b/login/acceptance/tests/code.ts @@ -0,0 +1,19 @@ +import { Page } from "@playwright/test"; +import { codeScreen } from "./code-screen"; +import { getOtpFromSink } from "./sink"; + +export async function otpFromSink(page: Page, key: string) { + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getOtpFromSink(key); + await code(page, c); +} + +export async function code(page: Page, code: string) { + await codeScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function codeResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/acceptance/tests/email-verify-screen.ts b/login/acceptance/tests/email-verify-screen.ts new file mode 100644 index 0000000000..b077ecb424 --- /dev/null +++ b/login/acceptance/tests/email-verify-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function emailVerifyScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function emailVerifyScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email"); +} diff --git a/login/acceptance/tests/email-verify.spec.ts b/login/acceptance/tests/email-verify.spec.ts new file mode 100644 index 0000000000..d95c1f691d --- /dev/null +++ b/login/acceptance/tests/email-verify.spec.ts @@ -0,0 +1,73 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { emailVerify, emailVerifyResend } from "./email-verify"; +import { emailVerifyScreenExpect } from "./email-verify-screen"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { getCodeFromSink } from "./sink"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: false, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("user email not verified, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + await emailVerifyResend(page); + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, old code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(user.getUsername()); + await emailVerifyResend(page); + // wait for resend of the code + await page.waitForTimeout(1000); + await emailVerify(page, c); + await emailVerifyScreenExpect(page, c); +}); + +test("user email not verified, wrong code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + const code = "wrong"; + await emailVerify(page, code); + await emailVerifyScreenExpect(page, code); +}); diff --git a/login/acceptance/tests/email-verify.ts b/login/acceptance/tests/email-verify.ts new file mode 100644 index 0000000000..dd7f74b29a --- /dev/null +++ b/login/acceptance/tests/email-verify.ts @@ -0,0 +1,15 @@ +import { Page } from "@playwright/test"; +import { emailVerifyScreen } from "./email-verify-screen"; + +export async function startEmailVerify(page: Page, loginname: string) { + await page.goto("/verify"); +} + +export async function emailVerify(page: Page, code: string) { + await emailVerifyScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function emailVerifyResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/acceptance/tests/idp-apple.spec.ts b/login/acceptance/tests/idp-apple.spec.ts new file mode 100644 index 0000000000..89ed734ece --- /dev/null +++ b/login/acceptance/tests/idp-apple.spec.ts @@ -0,0 +1,94 @@ +// Note for all tests, in case Apple doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Apple IDP", async ({ page }) => { + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User authenticates with Apple + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Apple IDP - error", async ({ page }) => { + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User is redirected to Apple + // User authenticates with Apple and gets an error + // User is redirect back to login + // An error is shown to the user "Something went wrong in Apple Login" +}); + +test("login with Apple IDP, no user existing - auto register", async ({ page }) => { + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Apple IDP, no user linked - auto link", async ({ page }) => { + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Apple IDP, no user linked, user link successful", async ({ page }) => { + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-generic-jwt.spec.ts b/login/acceptance/tests/idp-generic-jwt.spec.ts new file mode 100644 index 0000000000..054c147844 --- /dev/null +++ b/login/acceptance/tests/idp-generic-jwt.spec.ts @@ -0,0 +1,91 @@ +import test from "@playwright/test"; + +test("login with Generic JWT IDP", async ({ page }) => { + // Given a Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User authenticates with the Generic JWT IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic JWT IDP - error", async ({ page }) => { + // Given the Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User is redirected to the Generic JWT IDP + // User authenticates with the Generic JWT IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic JWT IDP, no user existing - auto register", async ({ page }) => { + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic JWT IDP, no user linked - auto link", async ({ page }) => { + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic JWT IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-generic-oauth.spec.ts b/login/acceptance/tests/idp-generic-oauth.spec.ts new file mode 100644 index 0000000000..7973e67c14 --- /dev/null +++ b/login/acceptance/tests/idp-generic-oauth.spec.ts @@ -0,0 +1,91 @@ +import test from "@playwright/test"; + +test("login with Generic OAuth IDP", async ({ page }) => { + // Given a Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User authenticates with the Generic OAuth IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OAuth IDP - error", async ({ page }) => { + // Given the Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User is redirected to the Generic OAuth IDP + // User authenticates with the Generic OAuth IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OAuth IDP, no user existing - auto register", async ({ page }) => { + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OAuth IDP, no user linked - auto link", async ({ page }) => { + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OAuth IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-generic-oidc.spec.ts b/login/acceptance/tests/idp-generic-oidc.spec.ts new file mode 100644 index 0000000000..4ed536f613 --- /dev/null +++ b/login/acceptance/tests/idp-generic-oidc.spec.ts @@ -0,0 +1,93 @@ +// Note, we should use a provider such as Google to test this, where we know OIDC standard is properly implemented + +import test from "@playwright/test"; + +test("login with Generic OIDC IDP", async ({ page }) => { + // Given a Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User authenticates with the Generic OIDC IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OIDC IDP - error", async ({ page }) => { + // Given the Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User is redirected to the Generic OIDC IDP + // User authenticates with the Generic OIDC IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OIDC IDP, no user existing - auto register", async ({ page }) => { + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OIDC IDP, no user linked - auto link", async ({ page }) => { + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OIDC IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-github-enterprise.spec.ts b/login/acceptance/tests/idp-github-enterprise.spec.ts new file mode 100644 index 0000000000..0a567c444b --- /dev/null +++ b/login/acceptance/tests/idp-github-enterprise.spec.ts @@ -0,0 +1,95 @@ +import test from "@playwright/test"; + +test("login with GitHub Enterprise IDP", async ({ page }) => { + // Given a GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User authenticates with the GitHub Enterprise IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub Enterprise IDP - error", async ({ page }) => { + // Given the GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User is redirected to the GitHub Enterprise IDP + // User authenticates with the GitHub Enterprise IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register", async ({ page }) => { + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub Enterprise IDP, no user linked - auto link", async ({ page }) => { + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub Enterprise IDP, no user linked, linking successful", async ({ page }) => { + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-github.spec.ts b/login/acceptance/tests/idp-github.spec.ts new file mode 100644 index 0000000000..be36a542f0 --- /dev/null +++ b/login/acceptance/tests/idp-github.spec.ts @@ -0,0 +1,95 @@ +import test from "@playwright/test"; + +test("login with GitHub IDP", async ({ page }) => { + // Given a GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User authenticates with the GitHub IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub IDP - error", async ({ page }) => { + // Given the GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User is redirected to the GitHub IDP + // User authenticates with the GitHub IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub IDP, no user existing - auto register", async ({ page }) => { + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub IDP, no user linked - auto link", async ({ page }) => { + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub IDP, no user linked, linking successful", async ({ page }) => { + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-gitlab-self-hosted.spec.ts b/login/acceptance/tests/idp-gitlab-self-hosted.spec.ts new file mode 100644 index 0000000000..d254062e2a --- /dev/null +++ b/login/acceptance/tests/idp-gitlab-self-hosted.spec.ts @@ -0,0 +1,95 @@ +import test from "@playwright/test"; + +test("login with GitLab Self-Hosted IDP", async ({ page }) => { + // Given a GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User authenticates with the GitLab Self-Hosted IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab Self-Hosted IDP - error", async ({ page }) => { + // Given the GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User is redirected to the GitLab Self-Hosted IDP + // User authenticates with the GitLab Self-Hosted IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register", async ({ page }) => { + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab Self-Hosted IDP, no user linked - auto link", async ({ page }) => { + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-gitlab.spec.ts b/login/acceptance/tests/idp-gitlab.spec.ts new file mode 100644 index 0000000000..e38b70a1e1 --- /dev/null +++ b/login/acceptance/tests/idp-gitlab.spec.ts @@ -0,0 +1,95 @@ +import test from "@playwright/test"; + +test("login with GitLab IDP", async ({ page }) => { + // Given a GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User authenticates with the GitLab IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab IDP - error", async ({ page }) => { + // Given the GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User is redirected to the GitLab IDP + // User authenticates with the GitLab IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab IDP, no user existing - auto register", async ({ page }) => { + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab IDP, no user linked - auto link", async ({ page }) => { + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-google.spec.ts b/login/acceptance/tests/idp-google.spec.ts new file mode 100644 index 0000000000..c6219722a9 --- /dev/null +++ b/login/acceptance/tests/idp-google.spec.ts @@ -0,0 +1,91 @@ +import test from "@playwright/test"; + +test("login with Google IDP", async ({ page }) => { + // Given a Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User authenticates with the Google IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Google IDP - error", async ({ page }) => { + // Given the Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User is redirected to the Google IDP + // User authenticates with the Google IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Google IDP, no user existing - auto register", async ({ page }) => { + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Google IDP, no user linked - auto link", async ({ page }) => { + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Google IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-ldap.spec.ts b/login/acceptance/tests/idp-ldap.spec.ts new file mode 100644 index 0000000000..9ab0310fa0 --- /dev/null +++ b/login/acceptance/tests/idp-ldap.spec.ts @@ -0,0 +1,91 @@ +import test from "@playwright/test"; + +test("login with LDAP IDP", async ({ page }) => { + // Given a LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User authenticates with the LDAP IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with LDAP IDP - error", async ({ page }) => { + // Given the LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User is redirected to the LDAP IDP + // User authenticates with the LDAP IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with LDAP IDP, no user existing - auto register", async ({ page }) => { + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with LDAP IDP, no user linked - auto link", async ({ page }) => { + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with LDAP IDP, no user linked, linking successful", async ({ page }) => { + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-microsoft.spec.ts b/login/acceptance/tests/idp-microsoft.spec.ts new file mode 100644 index 0000000000..40d44d577f --- /dev/null +++ b/login/acceptance/tests/idp-microsoft.spec.ts @@ -0,0 +1,94 @@ +// Note for all tests, in case Microsoft doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Microsoft IDP", async ({ page }) => { + // Given a Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User authenticates with the Microsoft IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Microsoft IDP - error", async ({ page }) => { + // Given the Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User is redirected to the Microsoft IDP + // User authenticates with the Microsoft IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Microsoft IDP, no user existing - auto register", async ({ page }) => { + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Microsoft IDP, no user linked - auto link", async ({ page }) => { + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Microsoft IDP, no user linked, linking successful", async ({ page }) => { + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/idp-saml.spec.ts b/login/acceptance/tests/idp-saml.spec.ts new file mode 100644 index 0000000000..e9e145909c --- /dev/null +++ b/login/acceptance/tests/idp-saml.spec.ts @@ -0,0 +1,95 @@ +import test from "@playwright/test"; + +test("login with SAML IDP", async ({ page }) => { + // Given a SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User authenticates with the SAML IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with SAML IDP - error", async ({ page }) => { + // Given the SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User is redirected to the SAML IDP + // User authenticates with the SAML IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with SAML IDP, no user existing - auto register", async ({ page }) => { + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register not possible", async ({ page }) => { + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with SAML IDP, no user linked - auto link", async ({ page }) => { + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user linked, linking not possible", async ({ page }) => { + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with SAML IDP, no user linked, linking successful", async ({ page }) => { + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/login-configuration-possiblities.spec.ts b/login/acceptance/tests/login-configuration-possiblities.spec.ts new file mode 100644 index 0000000000..f82f99364a --- /dev/null +++ b/login/acceptance/tests/login-configuration-possiblities.spec.ts @@ -0,0 +1,51 @@ +import test from "@playwright/test"; + +test("login with mfa setup, mfa setup prompt", async ({ page }) => { + // Given the organization has enabled at least one mfa types + // Given the user has a password but no mfa registered + // User authenticates with login name and password + // User is prompted to setup a mfa, mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, no mfa setup prompt", async ({ page }) => { + // Given the organization has set "multifactor init check time" to 0 + // Given the organization has enabled mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // user is directly loged in and not prompted to setup mfa +}); + +test("login with mfa setup, force mfa for local authenticated users", async ({ page }) => { + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user", async ({ page }) => { + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - external user", async ({ page }) => { + // Given the organization has enabled force mfa + // Given the organization has enabled all possible mfa types + // Given the user has an idp but no mfa registered + // enter login name + // redirect to configured external idp + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user, wrong password", async ({ page }) => { + // Given the organization has a password lockout policy set to 1 on the max password attempts + // Given the user has only a password as auth methos + // enter login name + // enter wrong password + // User will get an error "Wrong password" + // enter password + // User will get an error "Max password attempts reached - user is locked. Please reach out to your administrator" +}); diff --git a/login/acceptance/tests/login.ts b/login/acceptance/tests/login.ts new file mode 100644 index 0000000000..32c0007a3c --- /dev/null +++ b/login/acceptance/tests/login.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; +import { code, otpFromSink } from "./code"; +import { loginname } from "./loginname"; +import { password } from "./password"; +import { totp } from "./zitadel"; + +export async function startLogin(page: Page) { + await page.goto("/loginname"); +} + +export async function loginWithPassword(page: Page, username: string, pw: string) { + await startLogin(page); + await loginname(page, username); + await password(page, pw); +} + +export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) { + await startLogin(page); + await loginname(page, username); + // await passkey(page, authenticatorId); +} + +export async function loginScreenExpect(page: Page, fullName: string) { + await expect(page).toHaveURL(/signedin.*/); + await expect(page.getByRole("heading")).toContainText(fullName); +} + +export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, email); +} + +export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, phone); +} + +export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) { + await loginWithPassword(page, username, password); + await code(page, totp(secret)); +} diff --git a/login/acceptance/tests/loginname-screen.ts b/login/acceptance/tests/loginname-screen.ts new file mode 100644 index 0000000000..be41a28eda --- /dev/null +++ b/login/acceptance/tests/loginname-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const usernameTextInput = "username-text-input"; + +export async function loginnameScreen(page: Page, username: string) { + await page.getByTestId(usernameTextInput).pressSequentially(username); +} + +export async function loginnameScreenExpect(page: Page, username: string) { + await expect(page.getByTestId(usernameTextInput)).toHaveValue(username); + await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system"); +} diff --git a/login/acceptance/tests/loginname.ts b/login/acceptance/tests/loginname.ts new file mode 100644 index 0000000000..2050ec1d3c --- /dev/null +++ b/login/acceptance/tests/loginname.ts @@ -0,0 +1,7 @@ +import { Page } from "@playwright/test"; +import { loginnameScreen } from "./loginname-screen"; + +export async function loginname(page: Page, username: string) { + await loginnameScreen(page, username); + await page.getByTestId("submit-button").click(); +} diff --git a/login/acceptance/tests/passkey.ts b/login/acceptance/tests/passkey.ts new file mode 100644 index 0000000000..d8cda10ddb --- /dev/null +++ b/login/acceptance/tests/passkey.ts @@ -0,0 +1,109 @@ +import { expect, Page } from "@playwright/test"; +import { CDPSession } from "playwright-core"; + +interface session { + client: CDPSession; + authenticatorId: string; +} + +async function client(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return { client: cdpSession, authenticatorId: result.authenticatorId }; +} + +export async function passkeyRegister(page: Page): Promise { + const session = await client(page); + + await passkeyNotExisting(session.client, session.authenticatorId); + await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () => + page.getByTestId("submit-button").click(), + ); + await passkeyRegistered(session.client, session.authenticatorId); + + return session.authenticatorId; +} + +export async function passkey(page: Page, authenticatorId: string) { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + + const signCount = await passkeyExisting(cdpSession, authenticatorId); + + await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click()); + + await passkeyUsed(cdpSession, authenticatorId, signCount); +} + +async function passkeyNotExisting(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(0); +} + +async function passkeyRegistered(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + await passkeyUsed(client, authenticatorId, 0); +} + +async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + return result.credentials[0].signCount; +} + +async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].signCount).toBeGreaterThan(signCount); +} + +async function simulateSuccessfulPasskeyRegister( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAdded", () => { + console.log("Credential Added!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} + +async function simulateSuccessfulPasskeyInput( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAsserted", () => { + console.log("Credential Asserted!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} diff --git a/login/acceptance/tests/password-screen.ts b/login/acceptance/tests/password-screen.ts new file mode 100644 index 0000000000..6dff9a3a8f --- /dev/null +++ b/login/acceptance/tests/password-screen.ts @@ -0,0 +1,101 @@ +import { expect, Page } from "@playwright/test"; +import { getCodeFromSink } from "./sink"; + +const codeField = "code-text-input"; +const passwordField = "password-text-input"; +const passwordConfirmField = "password-confirm-text-input"; +const passwordChangeField = "password-change-text-input"; +const passwordChangeConfirmField = "password-change-confirm-text-input"; +const passwordSetField = "password-set-text-input"; +const passwordSetConfirmField = "password-set-confirm-text-input"; +const lengthCheck = "length-check"; +const symbolCheck = "symbol-check"; +const numberCheck = "number-check"; +const uppercaseCheck = "uppercase-check"; +const lowercaseCheck = "lowercase-check"; +const equalCheck = "equal-check"; + +const matchText = "Matches"; +const noMatchText = "Doesn't match"; + +export async function changePasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordChangeField).pressSequentially(password1); + await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2); +} + +export async function passwordScreen(page: Page, password: string) { + await page.getByTestId(passwordField).pressSequentially(password); +} + +export async function passwordScreenExpect(page: Page, password: string) { + await expect(page.getByTestId(passwordField)).toHaveValue(password); + await expect(page.getByTestId("error").locator("div")).toContainText("Failed to authenticate."); +} + +export async function changePasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1); + await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} + +async function checkComplexity( + page: Page, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await checkContent(page, lengthCheck, length); + await checkContent(page, symbolCheck, symbol); + await checkContent(page, numberCheck, number); + await checkContent(page, uppercaseCheck, uppercase); + await checkContent(page, lowercaseCheck, lowercase); + await checkContent(page, equalCheck, equals); +} + +async function checkContent(page: Page, testid: string, match: boolean) { + if (match) { + await expect(page.getByTestId(testid)).toContainText(matchText); + } else { + await expect(page.getByTestId(testid)).toContainText(noMatchText); + } +} + +export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) { + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(username); + await page.getByTestId(codeField).pressSequentially(c); + await page.getByTestId(passwordSetField).pressSequentially(password1); + await page.getByTestId(passwordSetConfirmField).pressSequentially(password2); +} + +export async function resetPasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordSetField)).toHaveValue(password1); + await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} diff --git a/login/acceptance/tests/password.ts b/login/acceptance/tests/password.ts new file mode 100644 index 0000000000..1dc304cc84 --- /dev/null +++ b/login/acceptance/tests/password.ts @@ -0,0 +1,29 @@ +import { Page } from "@playwright/test"; +import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen"; + +const passwordSubmitButton = "submit-button"; +const passwordResetButton = "reset-button"; + +export async function startChangePassword(page: Page, loginname: string) { + await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname })); +} + +export async function changePassword(page: Page, password: string) { + await changePasswordScreen(page, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function password(page: Page, password: string) { + await passwordScreen(page, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function startResetPassword(page: Page) { + await page.getByTestId(passwordResetButton).click(); +} + +export async function resetPassword(page: Page, username: string, password: string) { + await startResetPassword(page); + await resetPasswordScreen(page, username, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} diff --git a/login/acceptance/tests/register-screen.ts b/login/acceptance/tests/register-screen.ts new file mode 100644 index 0000000000..d14f5dc970 --- /dev/null +++ b/login/acceptance/tests/register-screen.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; + +const passwordField = "password-text-input"; +const passwordConfirmField = "password-confirm-text-input"; + +export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("password-radio").click(); +} + +export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("passkey-radio").click(); +} + +export async function registerPasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordField).pressSequentially(password1); + await page.getByTestId(passwordConfirmField).pressSequentially(password2); +} + +export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) { + await page.getByTestId("firstname-text-input").pressSequentially(firstname); + await page.getByTestId("lastname-text-input").pressSequentially(lastname); + await page.getByTestId("email-text-input").pressSequentially(email); + await page.getByTestId("privacy-policy-checkbox").check(); + await page.getByTestId("tos-checkbox").check(); +} diff --git a/login/acceptance/tests/register.spec.ts b/login/acceptance/tests/register.spec.ts new file mode 100644 index 0000000000..a3ffc7a67e --- /dev/null +++ b/login/acceptance/tests/register.spec.ts @@ -0,0 +1,173 @@ +import { faker } from "@faker-js/faker"; +import { test } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect } from "./login"; +import { registerWithPasskey, registerWithPassword } from "./register"; +import { removeUserByUsername } from "./zitadel"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +test("register with password", async ({ page }) => { + const username = faker.internet.email(); + const password = "Password1!"; + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPassword(page, firstname, lastname, username, password, password); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(2000); + await removeUserByUsername(username); +}); + +test("register with passkey", async ({ page }) => { + const username = faker.internet.email(); + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPasskey(page, firstname, lastname, username); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(2000); + await removeUserByUsername(username); +}); + +test("register with username and password - only password enabled", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and "password" + // User is redirected to app (default redirect url) +}); + +test("register with username and password - wrong password not enough characters", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password thats to short + // Error is shown "Password doesn't match the policy - it must have at least 8 characters" +}); + +test("register with username and password - wrong password number missing", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without a number + // Error is shown "Password doesn't match the policy - number missing" +}); + +test("register with username and password - wrong password upper case missing", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an upper case + // Error is shown "Password doesn't match the policy - uppercase letter missing" +}); + +test("register with username and password - wrong password lower case missing", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an lower case + // Error is shown "Password doesn't match the policy - lowercase letter missing" +}); + +test("register with username and password - wrong password symboo missing", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an symbol + // Error is shown "Password doesn't match the policy - symbol missing" +}); + +test("register with username and password - password and passkey enabled", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks password + // User enters password + // User is redirected to app (default redirect url) +}); + +test("register with username and passkey - password and passkey enabled", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks passkey + // Passkey is opened automatically + // User verifies passkey + // User is redirected to app (default redirect url) +}); + +test("register with username and password - registration disabled", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given user doesn't exist + // Button "register new user" is not available +}); + +test("register with username and password - multiple registration options", async ({ page }) => { + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization one idp is configured and enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration options + // Local User and idp button are shown + // User clicks idp button + // User enters "firstname", "lastname", "username" and "password" + // User clicks next + // User is redirected to app (default redirect url) +}); diff --git a/login/acceptance/tests/register.ts b/login/acceptance/tests/register.ts new file mode 100644 index 0000000000..19cb0f04fd --- /dev/null +++ b/login/acceptance/tests/register.ts @@ -0,0 +1,42 @@ +import { Page } from "@playwright/test"; +import { emailVerify } from "./email-verify"; +import { passkeyRegister } from "./passkey"; +import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; +import { getCodeFromSink } from "./sink"; + +export async function registerWithPassword( + page: Page, + firstname: string, + lastname: string, + email: string, + password1: string, + password2: string, +) { + await page.goto("/register"); + await registerUserScreenPassword(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + await registerPasswordScreen(page, password1, password2); + await page.getByTestId("submit-button").click(); + await page.waitForTimeout(3000); + + await verifyEmail(page, email); +} + +export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { + await page.goto("/register"); + await registerUserScreenPasskey(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + + // wait for projection of user + await page.waitForTimeout(3000); + const authId = await passkeyRegister(page); + + await verifyEmail(page, email); + return authId; +} + +async function verifyEmail(page: Page, email: string) { + await page.waitForTimeout(1000); + const c = await getCodeFromSink(email); + await emailVerify(page, c); +} diff --git a/login/acceptance/tests/select-account.ts b/login/acceptance/tests/select-account.ts new file mode 100644 index 0000000000..64bd7cd145 --- /dev/null +++ b/login/acceptance/tests/select-account.ts @@ -0,0 +1,5 @@ +import { Page } from "@playwright/test"; + +export async function selectNewAccount(page: Page) { + await page.getByRole("link", { name: "Add another account" }).click(); +} diff --git a/login/acceptance/tests/sink.ts b/login/acceptance/tests/sink.ts new file mode 100644 index 0000000000..fc13a98dc7 --- /dev/null +++ b/login/acceptance/tests/sink.ts @@ -0,0 +1,55 @@ +import axios from "axios"; + +export async function getOtpFromSink(key: string): Promise { + try { + const response = await axios.post( + process.env.SINK_NOTIFICATION_URL!, + { + recipient: key, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`, + }, + }, + ); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + return response.data.args.oTP; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function getCodeFromSink(key: string): Promise { + try { + const response = await axios.post( + process.env.SINK_NOTIFICATION_URL!, + { + recipient: key, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`, + }, + }, + ); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + return response.data.args.code; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} diff --git a/login/acceptance/tests/user.ts b/login/acceptance/tests/user.ts new file mode 100644 index 0000000000..68a8eecd2b --- /dev/null +++ b/login/acceptance/tests/user.ts @@ -0,0 +1,184 @@ +import { Page } from "@playwright/test"; +import { registerWithPasskey } from "./register"; +import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./zitadel"; + +export interface userProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; +} + +class User { + private readonly props: userProps; + private user: string; + + constructor(userProps: userProps) { + this.props = userProps; + } + + async ensure(page: Page) { + const response = await addUser(this.props); + + this.setUserId(response.userId); + } + + async cleanup() { + await removeUser(this.getUserId()); + } + + public setUserId(userId: string) { + this.user = userId; + } + + public getUserId() { + return this.user; + } + + public getUsername() { + return this.props.email; + } + + public getPassword() { + return this.props.password; + } + + public getFirstname() { + return this.props.firstName; + } + + public getLastname() { + return this.props.lastName; + } + + public getPhone() { + return this.props.phone; + } + + public getFullName() { + return `${this.props.firstName} ${this.props.lastName}`; + } +} + +export class PasswordUser extends User { + async ensure(page: Page) { + await super.ensure(page); + // wait for projection of user + await page.waitForTimeout(2000); + } +} + +export enum OtpType { + sms = "sms", + email = "email", +} + +export interface otpUserProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; + type: OtpType; +} + +export class PasswordUserWithOTP extends User { + private type: OtpType; + + constructor(props: otpUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: props.password, + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + passwordChangeRequired: props.passwordChangeRequired, + }); + this.type = props.type; + } + + async ensure(page: Page) { + await super.ensure(page); + + await activateOTP(this.getUserId(), this.type); + + // wait for projection of user + await page.waitForTimeout(2000); + } +} + +export class PasswordUserWithTOTP extends User { + private secret: string; + + async ensure(page: Page) { + await super.ensure(page); + + this.secret = await addTOTP(this.getUserId()); + + // wait for projection of user + await page.waitForTimeout(2000); + } + + public getSecret(): string { + return this.secret; + } +} + +export interface passkeyUserProps { + email: string; + firstName: string; + lastName: string; + organization: string; + phone: string; + isEmailVerified?: boolean; + isPhoneVerified?: boolean; +} + +export class PasskeyUser extends User { + private authenticatorId: string; + + constructor(props: passkeyUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: "", + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + }); + } + + public async ensure(page: Page) { + const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); + this.authenticatorId = authId; + + // wait for projection of user + await page.waitForTimeout(2000); + } + + async cleanup() { + const resp: any = await getUserByUsername(this.getUsername()); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); + } + + public getAuthenticatorId(): string { + return this.authenticatorId; + } +} diff --git a/login/acceptance/tests/username-passkey.spec.ts b/login/acceptance/tests/username-passkey.spec.ts new file mode 100644 index 0000000000..54b1bf0a29 --- /dev/null +++ b/login/acceptance/tests/username-passkey.spec.ts @@ -0,0 +1,42 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPasskey } from "./login"; +import { PasskeyUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasskeyUser }>({ + user: async ({ page }, use) => { + const user = new PasskeyUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and passkey login", async ({ user, page }) => { + await loginWithPasskey(page, user.getAuthenticatorId(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and passkey login, multiple auth methods", async ({ page }) => { + // Given passkey and password is enabled on the organization of the user + // Given the user has password and passkey registered + // enter username + // passkey popup is directly shown + // user aborts passkey authentication + // user switches to password authentication + // user enters password + // user is redirected to app +}); diff --git a/login/acceptance/tests/username-password-change-required.spec.ts b/login/acceptance/tests/username-password-change-required.spec.ts new file mode 100644 index 0000000000..50177d95e9 --- /dev/null +++ b/login/acceptance/tests/username-password-change-required.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword } from "./password"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: true, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login, change required", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await page.waitForTimeout(100); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); diff --git a/login/acceptance/tests/username-password-changed.spec.ts b/login/acceptance/tests/username-password-changed.spec.ts new file mode 100644 index 0000000000..c43ec13797 --- /dev/null +++ b/login/acceptance/tests/username-password-changed.spec.ts @@ -0,0 +1,54 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword, startChangePassword } from "./password"; +import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password changed login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + + // wait for projection of token + await page.waitForTimeout(2000); + + await startChangePassword(page, user.getUsername()); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password change not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await startChangePassword(page, user.getUsername()); + await changePasswordScreen(page, changedPw1, changedPw2); + await changePasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/acceptance/tests/username-password-otp_email.spec.ts b/login/acceptance/tests/username-password-otp_email.spec.ts new file mode 100644 index 0000000000..d06cc87834 --- /dev/null +++ b/login/acceptance/tests/username-password-otp_email.spec.ts @@ -0,0 +1,96 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code, codeResend, otpFromSink } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.email, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and email otp login, enter code manually", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, click link in email", async ({ page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks link in the email + // User is redirected to the app (default redirect url) +}); + +test("username, password and email otp login, resend code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks resend code + // User receives a new email with a verification code + // User enters the new code in the ui + // User is redirected to the app (default redirect url) + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await codeResend(page); + await otpFromSink(page, user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, wrong code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and email otp login, multiple mfa options", async ({ page }) => { + // Given email otp and sms otp is enabled on the organization of the user + // Given the user has email and sms otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks button to use sms otp as second factor + // User receives a sms with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/username-password-otp_sms.spec.ts b/login/acceptance/tests/username-password-otp_sms.spec.ts new file mode 100644 index 0000000000..ac69b25f08 --- /dev/null +++ b/login/acceptance/tests/username-password-otp_sms.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.sms, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and sms otp login, enter code manually", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and sms otp login, resend code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User clicks resend code + // User receives a new sms with a verification code + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and sms otp login, wrong code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); diff --git a/login/acceptance/tests/username-password-set.spec.ts b/login/acceptance/tests/username-password-set.spec.ts new file mode 100644 index 0000000000..dcdfbb1c52 --- /dev/null +++ b/login/acceptance/tests/username-password-set.spec.ts @@ -0,0 +1,52 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { resetPassword, startResetPassword } from "./password"; +import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password set login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await startLogin(page); + await loginname(page, user.getUsername()); + await resetPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password set not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await startLogin(page); + await loginname(page, user.getUsername()); + await startResetPassword(page); + await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2); + await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/acceptance/tests/username-password-totp.spec.ts b/login/acceptance/tests/username-password-totp.spec.ts new file mode 100644 index 0000000000..e897cd7748 --- /dev/null +++ b/login/acceptance/tests/username-password-totp.spec.ts @@ -0,0 +1,70 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login"; +import { PasswordUserWithTOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithTOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and totp login", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and totp otp login, wrong code", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters a wrond code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and totp login, multiple mfa options", async ({ page }) => { + // Given totp and email otp is enabled on the organization of the user + // Given the user has totp and email otp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // Button to switch to email otp is shown + // User clicks button to use email otp instead + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/username-password-u2f.spec.ts b/login/acceptance/tests/username-password-u2f.spec.ts new file mode 100644 index 0000000000..f6f918478f --- /dev/null +++ b/login/acceptance/tests/username-password-u2f.spec.ts @@ -0,0 +1,24 @@ +import { test } from "@playwright/test"; + +test("username, password and u2f login", async ({ page }) => { + // Given u2f is enabled on the organization of the user + // Given the user has only u2f configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User verifies u2f + // User is redirected to the app (default redirect url) +}); + +test("username, password and u2f login, multiple mfa options", async ({ page }) => { + // Given u2f and semailms otp is enabled on the organization of the user + // Given the user has u2f and email otp configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User aborts u2f verification + // User clicks button to use email otp as second factor + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/acceptance/tests/username-password.spec.ts b/login/acceptance/tests/username-password.spec.ts new file mode 100644 index 0000000000..209c415511 --- /dev/null +++ b/login/acceptance/tests/username-password.spec.ts @@ -0,0 +1,145 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { loginnameScreenExpect } from "./loginname-screen"; +import { password } from "./password"; +import { passwordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and password login, unknown username", async ({ page }) => { + const username = "unknown"; + await startLogin(page); + await loginname(page, username); + await loginnameScreenExpect(page, username); +}); + +test("username and password login, wrong password", async ({ user, page }) => { + await startLogin(page); + await loginname(page, user.getUsername()); + await password(page, "wrong"); + await passwordScreenExpect(page, "wrong"); +}); + +test("username and password login, wrong username, ignore unknown usernames", async ({ user, page }) => { + // Given user doesn't exist but ignore unknown usernames setting is set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // redirect to loginname page --> error message username or password wrong +}); + +test("username and password login, initial password change", async ({ user, page }) => { + // Given user is created and has changePassword set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // create new password +}); + +test("username and password login, reset password hidden", async ({ user, page }) => { + // Given the organization has enabled "Password reset hidden" in the login policy + // Given username password login is enabled on the users organization + // enter login name + // password reset link should not be shown on password screen +}); + +test("username and password login, reset password - enter code manually", async ({ user, page }) => { + // Given user has forgotten password and clicks the forgot password button + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // enter code from email + // user is redirected to app (default redirect url) +}); + +test("username and password login, reset password - click link", async ({ user, page }) => { + // Given user has forgotten password and clicks the forgot password button, and then the link in the email + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click link in email + // set new password + // redirect to app (default redirect url) +}); + +test("username and password login, reset password, resend code", async ({ user, page }) => { + // Given user has forgotten password and clicks the forgot password button and then resend code + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click resend code + // enter code from second email + // user is redirected to app (default redirect url) +}); + +test("email login enabled", async ({ user, page }) => { + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com " in login screen + // user will get to password screen +}); + +test("email login disabled", async ({ user, page }) => { + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("email login enabled - multiple users", async ({ user, page }) => { + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled", async ({ user, page }) => { + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phon number exists + // enter phone number "0711111111" in login screen + // user will get to password screen +}); + +test("phone login disabled", async ({ user, page }) => { + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phone number exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled - multiple users", async ({ user, page }) => { + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); diff --git a/login/acceptance/tests/welcome.ts b/login/acceptance/tests/welcome.ts new file mode 100644 index 0000000000..7ff6b7d1c5 --- /dev/null +++ b/login/acceptance/tests/welcome.ts @@ -0,0 +1,6 @@ +import { test } from "@playwright/test"; + +test("login is accessible", async ({ page }) => { + await page.goto("http://localhost:3000/"); + await page.getByRole("heading", { name: "Welcome back!" }).isVisible(); +}); diff --git a/login/acceptance/tests/zitadel.ts b/login/acceptance/tests/zitadel.ts new file mode 100644 index 0000000000..ae29bf84e5 --- /dev/null +++ b/login/acceptance/tests/zitadel.ts @@ -0,0 +1,166 @@ +import { Authenticator } from "@otplib/core"; +import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; +import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin +import axios from "axios"; +import { OtpType, userProps } from "./user"; + +export async function addUser(props: userProps) { + const body = { + username: props.email, + organization: { + orgId: props.organization, + }, + profile: { + givenName: props.firstName, + familyName: props.lastName, + }, + email: { + email: props.email, + isVerified: true, + }, + phone: { + phone: props.phone, + isVerified: true, + }, + password: { + password: props.password, + changeRequired: props.passwordChangeRequired ?? false, + }, + }; + if (!props.isEmailVerified) { + delete body.email.isVerified; + } + if (!props.isPhoneVerified) { + delete body.phone.isVerified; + } + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); +} + +export async function removeUserByUsername(username: string) { + const resp = await getUserByUsername(username); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); +} + +export async function removeUser(id: string) { + await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`); +} + +async function deleteCall(url: string) { + try { + const response = await axios.delete(url, { + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400 && response.status !== 404) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function getUserByUsername(username: string): Promise { + const listUsersBody = { + queries: [ + { + userNameQuery: { + userName: username, + }, + }, + ], + }; + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody); +} + +async function listCall(url: string, data: any): Promise { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + + return response.data; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function activateOTP(userId: string, type: OtpType) { + let url = "otp_"; + switch (type) { + case OtpType.sms: + url = url + "sms"; + break; + case OtpType.email: + url = url + "email"; + break; + } + + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {}); +} + +async function pushCall(url: string, data: any) { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function addTOTP(userId: string): Promise { + const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {}); + const code = totp(response.secret); + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code }); + return response.secret; +} + +export function totp(secret: string) { + const authenticator = new Authenticator({ + createDigest, + createRandomBytes, + keyDecoder, + keyEncoder, + }); + // google authenticator usage + const token = authenticator.generate(secret); + + // check if token can be used + if (!authenticator.verify({ token: token, secret: secret })) { + const error = `Generated token could not be verified`; + console.error(error); + throw new Error(error); + } + + return token; +} diff --git a/login/acceptance/zitadel.yaml b/login/acceptance/zitadel.yaml new file mode 100644 index 0000000000..0678e8ff86 --- /dev/null +++ b/login/acceptance/zitadel.yaml @@ -0,0 +1,69 @@ +FirstInstance: + PatPath: /pat/zitadel-admin-sa.pat + Org: + Human: + UserName: zitadel-admin + FirstName: ZITADEL + LastName: Admin + Password: Password1! + PasswordChangeRequired: false + PreferredLanguage: en + Machine: + Machine: + Username: zitadel-admin-sa + Name: Admin + Pat: + ExpirationDate: 2099-01-01T00:00:00Z + +DefaultInstance: + LoginPolicy: + AllowUsernamePassword: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWUSERNAMEPASSWORD + AllowRegister: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER + AllowExternalIDP: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWEXTERNALIDP + ForceMFA: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_FORCEMFA + HidePasswordReset: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_HIDEPASSWORDRESET + IgnoreUnknownUsernames: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_IGNOREUNKNOWNUSERNAMES + AllowDomainDiscovery: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWDOMAINDISCOVERY + # 1 is allowed, 0 is not allowed + PasswordlessType: 1 # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDLESSTYPE + # DefaultRedirectURL is empty by default because we use the Console UI + DefaultRedirectURI: # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_DEFAULTREDIRECTURI + # 240h = 10d + PasswordCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDCHECKLIFETIME + # 240h = 10d + ExternalLoginCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_EXTERNALLOGINCHECKLIFETIME + # 720h = 30d + MfaInitSkipLifetime: 0h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME + SecondFactorCheckLifetime: 18h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_SECONDFACTORCHECKLIFETIME + MultiFactorCheckLifetime: 12h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MULTIFACTORCHECKLIFETIME + PrivacyPolicy: + TOSLink: "https://zitadel.com/docs/legal/terms-of-service" + PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy" + HelpLink: "https://zitadel.com/docs" + SupportEmail: "support@zitadel.com" + DocsLink: "https://zitadel.com/docs" + +Database: + EventPushConnRatio: 0.2 # 4 + ProjectionSpoolerConnRatio: 0.3 # 6 + postgres: + Host: db + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 20 + MaxConnLifetime: 1h + MaxConnIdleTime: 5m + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: zitadel + SSL: + Mode: disable + +Logstore: + Access: + Stdout: + Enabled: true diff --git a/login/apps/login/.env.integration b/login/apps/login/.env.integration new file mode 100644 index 0000000000..a72ccdecd2 --- /dev/null +++ b/login/apps/login/.env.integration @@ -0,0 +1,5 @@ +ZITADEL_API_URL=http://localhost:22222 +ZITADEL_SERVICE_USER_TOKEN="yolo" +EMAIL_VERIFICATION=true +DEBUG=true +NEXT_PUBLIC_BASE_PATH="" \ No newline at end of file diff --git a/login/apps/login/.eslintrc.cjs b/login/apps/login/.eslintrc.cjs new file mode 100755 index 0000000000..f5383dd47a --- /dev/null +++ b/login/apps/login/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + extends: ["next/core-web-vitals"], + ignorePatterns: ["external/**/*.ts"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/login/apps/login/.gitignore b/login/apps/login/.gitignore new file mode 100644 index 0000000000..63ddd0c9eb --- /dev/null +++ b/login/apps/login/.gitignore @@ -0,0 +1,2 @@ +custom-config.js +.env.local \ No newline at end of file diff --git a/login/apps/login/.prettierignore b/login/apps/login/.prettierignore new file mode 100644 index 0000000000..dbcbbd11d1 --- /dev/null +++ b/login/apps/login/.prettierignore @@ -0,0 +1,2 @@ +.next +/external \ No newline at end of file diff --git a/login/apps/login/constants/csp.js b/login/apps/login/constants/csp.js new file mode 100644 index 0000000000..5cc1e254f3 --- /dev/null +++ b/login/apps/login/constants/csp.js @@ -0,0 +1,2 @@ +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; diff --git a/login/apps/login/cypress/.gitignore b/login/apps/login/cypress/.gitignore new file mode 100644 index 0000000000..2ca81ab137 --- /dev/null +++ b/login/apps/login/cypress/.gitignore @@ -0,0 +1,2 @@ +screenshots +videos \ No newline at end of file diff --git a/login/apps/login/cypress/cypress.config.ts b/login/apps/login/cypress/cypress.config.ts new file mode 100644 index 0000000000..ed880fb48c --- /dev/null +++ b/login/apps/login/cypress/cypress.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + reporter: "list", + e2e: { + baseUrl: "http://localhost:3000", + specPattern: "cypress/integration/**/*.cy.{js,jsx,ts,tsx}", + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/login/apps/login/cypress/fixtures/example.json b/login/apps/login/cypress/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/login/apps/login/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/login/apps/login/cypress/integration/invite.cy.ts b/login/apps/login/cypress/integration/invite.cy.ts new file mode 100644 index 0000000000..3014f5a2e5 --- /dev/null +++ b/login/apps/login/cypress/integration/invite.cy.ts @@ -0,0 +1,114 @@ +import { stub } from "../support/mock"; + +describe("verify invite", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [], // user with no auth methods was invited + }, + }); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + + it.only("shows authenticators after successful invite verification", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode"); + + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.location("pathname", { timeout: 10_000 }).should( + "eq", + "/authenticator/set", + ); + }); + + it("shows an error if invite code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode", { + code: 3, + error: "error validating code", + }); + + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.contains("Could not verify invite", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login/cypress/integration/login.cy.ts b/login/apps/login/cypress/integration/login.cy.ts new file mode 100644 index 0000000000..3e74c0f7fe --- /dev/null +++ b/login/apps/login/cypress/integration/login.cy.ts @@ -0,0 +1,174 @@ +import { stub } from "../support/mock"; + +describe("login", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + describe("password login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // 1 for password authentication + }, + }); + }); + it("should redirect a user with password authentication to /password", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + }); + describe("with passkey prompt", () => { + beforeEach(() => { + stub("zitadel.session.v2.SessionService", "SetSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + }); + // it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { + // cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + // cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + // cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); + // cy.get('button[type="submit"]').click(); + // cy.location("pathname", { timeout: 10_000 }).should( + // "eq", + // "/passkey/set", + // ); + // }); + }); + }); + describe("passkey login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [2], // 2 for passwordless authentication + }, + }); + }); + + it("should redirect a user with passwordless authentication to /passkey", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey"); + }); + }); +}); diff --git a/login/apps/login/cypress/integration/register-idp.cy.ts b/login/apps/login/cypress/integration/register-idp.cy.ts new file mode 100644 index 0000000000..6b320f1775 --- /dev/null +++ b/login/apps/login/cypress/integration/register-idp.cy.ts @@ -0,0 +1,21 @@ +import { stub } from "../support/mock"; + +const IDP_URL = "https://example.com/idp/url"; + +describe("register idps", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "StartIdentityProviderIntent", { + data: { + authUrl: IDP_URL, + }, + }); + }); + + it("should redirect the user to the correct url", () => { + cy.visit("/idp"); + cy.get('button[e2e="google"]').click(); + cy.origin(IDP_URL, { args: IDP_URL }, (url) => { + cy.location("href", { timeout: 10_000 }).should("eq", url); + }); + }); +}); diff --git a/login/apps/login/cypress/integration/register.cy.ts b/login/apps/login/cypress/integration/register.cy.ts new file mode 100644 index 0000000000..262302c4c3 --- /dev/null +++ b/login/apps/login/cypress/integration/register.cy.ts @@ -0,0 +1,76 @@ +import { stub } from "../support/mock"; + +describe("register", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowRegister: true, + allowUsernamePassword: true, + defaultRedirectUri: "", + }, + }, + }); + stub("zitadel.user.v2.UserService", "AddHumanUser", { + data: { + userId: "221394658884845598", + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("should redirect a user who selects passwordless on register to /passkey/set", () => { + cy.visit("/register"); + cy.get('input[data-testid="firstname-text-input"]').focus().type("John"); + cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe"); + cy.get('input[data-testid="email-text-input"]') + .focus() + .type("john@zitadel.com"); + cy.get('input[type="checkbox"][value="privacypolicy"]').check(); + cy.get('input[type="checkbox"][value="tos"]').check(); + cy.get('button[type="submit"]').click(); + cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey/set"); + }); +}); diff --git a/login/apps/login/cypress/integration/verify.cy.ts b/login/apps/login/cypress/integration/verify.cy.ts new file mode 100644 index 0000000000..464bf02e59 --- /dev/null +++ b/login/apps/login/cypress/integration/verify.cy.ts @@ -0,0 +1,96 @@ +import { stub } from "../support/mock"; + +describe("verify email", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // set one method such that we know that the user was not invited + }, + }); + + stub("zitadel.user.v2.UserService", "SendEmailCode"); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, // email is not verified yet + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: + "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("shows an error if email code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyEmail", { + code: 3, + error: "error validating code", + }); + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.contains("Could not verify email", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login/cypress/support/commands.ts b/login/apps/login/cypress/support/commands.ts new file mode 100644 index 0000000000..95857aea4c --- /dev/null +++ b/login/apps/login/cypress/support/commands.ts @@ -0,0 +1,37 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } diff --git a/login/apps/login/cypress/support/e2e.ts b/login/apps/login/cypress/support/e2e.ts new file mode 100644 index 0000000000..6a173d6fcb --- /dev/null +++ b/login/apps/login/cypress/support/e2e.ts @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import "./commands"; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/login/apps/login/cypress/support/mock.ts b/login/apps/login/cypress/support/mock.ts new file mode 100644 index 0000000000..84c33b8c2d --- /dev/null +++ b/login/apps/login/cypress/support/mock.ts @@ -0,0 +1,27 @@ +function removeStub(service: string, method: string) { + return cy.request({ + url: "http://localhost:22220/v1/stubs", + method: "DELETE", + qs: { + service, + method, + }, + }); +} + +export function stub(service: string, method: string, out?: any) { + removeStub(service, method); + return cy.request({ + url: "http://localhost:22220/v1/stubs", + method: "POST", + body: { + stubs: [ + { + service, + method, + out, + }, + ], + }, + }); +} diff --git a/login/apps/login/cypress/tsconfig.json b/login/apps/login/cypress/tsconfig.json new file mode 100644 index 0000000000..830efdd0ba --- /dev/null +++ b/login/apps/login/cypress/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/login/apps/login/locales/de.json b/login/apps/login/locales/de.json new file mode 100644 index 0000000000..36dc145120 --- /dev/null +++ b/login/apps/login/locales/de.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "logout": { + "title": "Logout", + "description": "Wählen Sie den Account aus, das Sie entfernen möchten", + "noResults": "Keine Konten gefunden", + "clear": "Session beenden", + "verifiedAt": "Zuletzt aktiv: {time}", + "success": { + "title": "Logout erfolgreich", + "description": "Sie haben sich erfolgreich abgemeldet." + } + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + }, + "completeRegister": { + "title": "Registrierung abschließen", + "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." + } + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "noMethodAvailableWarning": "Keine Authentifizierungsmethode verfügbar. Bitte wenden Sie sich an den Administrator.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "orUseIDP": "oder verwenden Sie einen Identitätsanbieter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "successTitle": "Benutzer verifiziert", + "successDescription": "Der Benutzer wurde erfolgreich verifiziert.", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } +} diff --git a/login/apps/login/locales/en.json b/login/apps/login/locales/en.json new file mode 100644 index 0000000000..0b1cbeb472 --- /dev/null +++ b/login/apps/login/locales/en.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "logout": { + "title": "Logout", + "description": "Click an account to end the session", + "noResults": "No accounts found", + "clear": "End Session", + "verifiedAt": "Last active: {time}", + "success": { + "title": "Logout successful", + "description": "You have successfully logged out." + } + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + }, + "completeRegister": { + "title": "Complete your data", + "description": "You need to complete your registration by providing your email address and name." + } + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "noMethodAvailableWarning": "No authentication method available. Please contact your administrator.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "orUseIDP": "or use an Identity Provider", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "successTitle": "User verified", + "successDescription": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "codeSent": "A code has just been sent to your email address.", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } +} diff --git a/login/apps/login/locales/es.json b/login/apps/login/locales/es.json new file mode 100644 index 0000000000..5cd40f764a --- /dev/null +++ b/login/apps/login/locales/es.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "logout": { + "title": "Cerrar sesión", + "description": "Selecciona la cuenta que deseas eliminar", + "noResults": "No se encontraron cuentas", + "clear": "Eliminar sesión", + "verifiedAt": "Última actividad: {time}", + "success": { + "title": "Cierre de sesión exitoso", + "description": "Has cerrado sesión correctamente." + } + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + }, + "completeRegister": { + "title": "Completar registro", + "description": "Para completar el registro, debes establecer una contraseña." + } + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "noMethodAvailableWarning": "No hay métodos de autenticación disponibles. Por favor, contacta a tu administrador.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "orUseIDP": "o usa un Proveedor de Identidad", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "successTitle": "Usuario verificado", + "successDescription": "El usuario ha sido verificado con éxito.", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } +} diff --git a/login/apps/login/locales/it.json b/login/apps/login/locales/it.json new file mode 100644 index 0000000000..a19aa91cfb --- /dev/null +++ b/login/apps/login/locales/it.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "logout": { + "title": "Esci", + "description": "Seleziona l'account che desideri uscire", + "noResults": "Nessun account trovato", + "clear": "Elimina sessione", + "verifiedAt": "Ultima attività: {time}", + "success": { + "title": "Uscita riuscita", + "description": "Hai effettuato l'uscita con successo." + } + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + }, + "completeRegister": { + "title": "Completa la registrazione", + "description": "Completa la registrazione del tuo account." + } + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "noMethodAvailableWarning": "Nessun metodo di autenticazione disponibile. Contatta l'amministratore di sistema per assistenza.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "orUseIDP": "o usa un Identity Provider", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "successTitle": "Utente verificato", + "successDescription": "L'utente è stato verificato con successo.", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } +} diff --git a/login/apps/login/locales/pl.json b/login/apps/login/locales/pl.json new file mode 100644 index 0000000000..b97e7e4b47 --- /dev/null +++ b/login/apps/login/locales/pl.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "logout": { + "title": "Wyloguj się", + "description": "Wybierz konto, które chcesz usunąć", + "noResults": "Nie znaleziono kont", + "clear": "Usuń sesję", + "verifiedAt": "Ostatnia aktywność: {time}", + "success": { + "title": "Wylogowanie udane", + "description": "Pomyślnie się wylogowałeś." + } + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + }, + "completeRegister": { + "title": "Ukończ rejestrację", + "description": "Ukończ rejestrację swojego konta." + } + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "noMethodAvailableWarning": "Brak dostępnych metod uwierzytelniania. Skontaktuj się z administratorem.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "orUseIDP": "lub użyj dostawcy tożsamości", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "successTitle": "Weryfikacja zakończona", + "successDescription": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } +} diff --git a/login/apps/login/locales/ru.json b/login/apps/login/locales/ru.json new file mode 100644 index 0000000000..77ea8ba79e --- /dev/null +++ b/login/apps/login/locales/ru.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "logout": { + "title": "Выход", + "description": "Выберите аккаунт, который хотите удалить", + "noResults": "Аккаунты не найдены", + "clear": "Удалить сессию", + "verifiedAt": "Последняя активность: {time}", + "success": { + "title": "Выход выполнен успешно", + "description": "Вы успешно вышли из системы." + } + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + }, + "completeRegister": { + "title": "Завершите регистрацию", + "description": "Завершите регистрацию вашего аккаунта." + } + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "noMethodAvailableWarning": "Нет доступных методов аутентификации. Обратитесь к администратору.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "orUseIDP": "или используйте Identity Provider", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "successTitle": "Пользователь подтверждён", + "successDescription": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "codeSent": "Код отправлен на ваш email.", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } +} diff --git a/login/apps/login/locales/zh.json b/login/apps/login/locales/zh.json new file mode 100644 index 0000000000..0ad9c7e056 --- /dev/null +++ b/login/apps/login/locales/zh.json @@ -0,0 +1,241 @@ +{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "logout": { + "title": "注销", + "description": "选择您想注销的账户", + "noResults": "未找到账户", + "clear": "注销会话", + "verifiedAt": "最后活动时间:{time}", + "success": { + "title": "注销成功", + "description": "您已成功注销。" + } + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + }, + "completeRegister": { + "title": "完成注册", + "description": "完成您的账户注册。" + } + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "orUseIDP": "或使用身份提供者", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "successTitle": "用户已验证", + "successDescription": "用户已成功验证。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "codeSent": "刚刚发送了一封包含验证码的电子邮件。", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } +} diff --git a/login/apps/login/mock/Dockerfile b/login/apps/login/mock/Dockerfile new file mode 100644 index 0000000000..9f08b20bae --- /dev/null +++ b/login/apps/login/mock/Dockerfile @@ -0,0 +1,20 @@ +FROM bufbuild/buf:1.21.0 as protos + +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto +RUN buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto +RUN buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto +RUN buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /proto + +FROM scratch AS config + +COPY mocked-services.cfg . +COPY initial-stubs initial-stubs +COPY --from=protos /proto . + +FROM golang:1.20.5-alpine3.18 as grpc-mock + +RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c + +COPY --from=config / . + +ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs" ] diff --git a/login/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/login/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json new file mode 100644 index 0000000000..3da4ae999f --- /dev/null +++ b/login/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -0,0 +1,59 @@ +[ + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetBrandingSettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetSecuritySettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetLegalAndSupportSettings", + "out": { + "data": { + "settings": { + "tosLink": "http://whatever.com/help", + "privacyPolicyLink": "http://whatever.com/help", + "helpLink": "http://whatever.com/help" + } + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetActiveIdentityProviders", + "out": { + "data": { + "identityProviders": [ + { + "id": "123", + "name": "Hubba bubba", + "type": 10 + } + ] + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetPasswordComplexitySettings", + "out": { + "data": { + "settings": { + "minLength": 8, + "requiresUppercase": true, + "requiresLowercase": true, + "requiresNumber": true, + "requiresSymbol": true + } + } + } + } +] diff --git a/login/apps/login/mock/mocked-services.cfg b/login/apps/login/mock/mocked-services.cfg new file mode 100644 index 0000000000..6a758ab8c1 --- /dev/null +++ b/login/apps/login/mock/mocked-services.cfg @@ -0,0 +1,7 @@ +zitadel/user/v2/user_service.proto +zitadel/org/v2/org_service.proto +zitadel/session/v2/session_service.proto +zitadel/settings/v2/settings_service.proto +zitadel/management.proto +zitadel/auth.proto +zitadel/admin.proto \ No newline at end of file diff --git a/login/apps/login/next-env-vars.d.ts b/login/apps/login/next-env-vars.d.ts new file mode 100644 index 0000000000..691bfa6f56 --- /dev/null +++ b/login/apps/login/next-env-vars.d.ts @@ -0,0 +1,33 @@ +declare namespace NodeJS { + interface ProcessEnv { + // Allow any environment variable that matches the pattern + [key: `${string}_AUDIENCE`]: string; // The system api url + [key: `${string}_SYSTEM_USER_ID`]: string; // The service user id + [key: `${string}_SYSTEM_USER_PRIVATE_KEY`]: string; // The service user private key + + AUDIENCE: string; // The fallback system api url + SYSTEM_USER_ID: string; // The fallback service user id + SYSTEM_USER_PRIVATE_KEY: string; // The fallback service user private key + + /** + * The Zitadel API url + */ + ZITADEL_API_URL: string; + + /** + * The service user token + */ + ZITADEL_SERVICE_USER_TOKEN: string; + + /** + * Optional: wheter a user must have verified email + */ + EMAIL_VERIFICATION: string; + + /** + * Optional: custom request headers to be added to every request + * Split by comma, key value pairs separated by colon + */ + CUSTOM_REQUEST_HEADERS: string; + } +} diff --git a/login/apps/login/next-env.d.ts b/login/apps/login/next-env.d.ts new file mode 100755 index 0000000000..1b3be0840f --- /dev/null +++ b/login/apps/login/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/login/apps/login/next.config.mjs b/login/apps/login/next.config.mjs new file mode 100755 index 0000000000..edf5e54595 --- /dev/null +++ b/login/apps/login/next.config.mjs @@ -0,0 +1,82 @@ +import createNextIntlPlugin from "next-intl/plugin"; +import { DEFAULT_CSP } from "./constants/csp.js"; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ + +const secureHeaders = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Referrer-Policy", + value: "origin-when-cross-origin", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + // img-src vercel.com needed for deploy button, + // script-src va.vercel-scripts.com for analytics/vercel scripts + { + key: "Content-Security-Policy", + value: `${DEFAULT_CSP} frame-ancestors 'none'`, + }, + { key: "X-Frame-Options", value: "deny" }, +]; + +const imageRemotePatterns = [ + { + protocol: "http", + hostname: "localhost", + port: "8080", + pathname: "/**", + }, + { + protocol: "https", + hostname: "*.zitadel.*", + port: "", + pathname: "/**", + }, +]; + +if (process.env.ZITADEL_API_URL) { + imageRemotePatterns.push({ + protocol: "https", + hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "", + port: "", + pathname: "/**", + }); +} + +const nextConfig = { + basePath: process.env.NEXT_PUBLIC_BASE_PATH, + output: process.env.NEXT_OUTPUT_MODE || undefined, + reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. + experimental: { + dynamicIO: true, + }, + images: { + remotePatterns: imageRemotePatterns, + }, + async headers() { + return [ + { + source: "/:path*", + headers: secureHeaders, + }, + ]; + }, +}; + +export default withNextIntl(nextConfig); diff --git a/login/apps/login/package.json b/login/apps/login/package.json new file mode 100644 index 0000000000..b8afa2007f --- /dev/null +++ b/login/apps/login/package.json @@ -0,0 +1,96 @@ +{ + "name": "@zitadel/login", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --turbopack", + "test": "concurrently --timings --kill-others-on-fail 'npm:test:unit' 'npm:test:integration'", + "test:watch": "concurrently --kill-others 'npm:test:unit:watch' 'npm:test:integration:watch'", + "test:unit": "vitest", + "test:unit:watch": "pnpm test:unit --watch", + "test:integration": "pnpm mock:build && concurrently --names 'mock,test' --success command-test --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test start http://localhost:3000 \"test:integration:run\"'", + "test:integration:watch:run": "concurrently --names 'mock,test' --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test dev http://localhost:3000 \"pnpm nodemon -e js,jsx,ts,tsx,css,scss --ignore \\\"__test__/**\\\" --exec \\\"pnpm test:integration:run\\\"\"'", + "test:integration:watch:open": "concurrently --names 'mock,test' --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test dev http://localhost:3000 \"pnpm nodemon -e js,jsx,ts,tsx,css,scss --ignore \\\"__test__/**\\\" --exec \\\"pnpm test:integration:open\\\"\"'", + "test:integration:run": "cypress run --config-file ./cypress/cypress.config.ts --quiet", + "test:integration:open": "cypress open --config-file ./cypress/cypress.config.ts", + "mock": "pnpm mock:build && pnpm mock:run", + "mock:run": "pnpm mock:stop && docker run --rm --name zitadel-mock-grpc-server --publish 22220:22220 --publish 22222:22222 zitadel-mock-grpc-server", + "mock:build": "DOCKER_BUILDKIT=1 docker build --tag zitadel-mock-grpc-server ./mock", + "mock:build:nocache": "pnpm mock:build --no-cache", + "mock:stop": "docker rm --force zitadel-mock-grpc-server 2>/dev/null || true", + "mock:destroy": "docker rmi --force zitadel-mock-grpc-server 2>/dev/null || true", + "lint": "next lint && prettier --check .", + "lint:fix": "prettier --write .", + "lint-staged": "lint-staged", + "build": "next build", + "build:standalone": "NEXT_OUTPUT_MODE=standalone pnpm build", + "prestart": "pnpm build", + "start": "next start", + "start:built": "next start", + "clean": "pnpm mock:destroy && rm -rf .turbo && rm -rf node_modules && rm -rf .next" + }, + "git": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "@headlessui/react": "^2.1.9", + "@heroicons/react": "2.1.3", + "@tailwindcss/forms": "0.5.7", + "@vercel/analytics": "^1.2.2", + "@zitadel/client": "workspace:*", + "@zitadel/proto": "workspace:*", + "clsx": "1.2.1", + "copy-to-clipboard": "^3.3.3", + "deepmerge": "^4.3.1", + "jose": "^5.3.0", + "lucide-react": "0.469.0", + "moment": "^2.29.4", + "next": "15.4.0-canary.3", + "next-intl": "^3.25.1", + "next-themes": "^0.2.1", + "nice-grpc": "2.0.1", + "qrcode.react": "^3.1.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-hook-form": "7.39.5", + "swr": "^2.2.0", + "tinycolor2": "1.4.2", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/ms": "2.1.0", + "@types/node": "^22.14.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "^10.0.0", + "@vercel/git-hooks": "1.0.0", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "@zitadel/tailwind-config": "workspace:*", + "@zitadel/tsconfig": "workspace:*", + "autoprefixer": "10.4.21", + "concurrently": "^9.1.2", + "cypress": "^14.3.2", + "del-cli": "6.0.0", + "env-cmd": "^10.0.0", + "grpc-tools": "1.13.0", + "jsdom": "^26.1.0", + "lint-staged": "15.5.1", + "make-dir-cli": "4.0.0", + "nodemon": "^3.1.9", + "postcss": "8.5.3", + "prettier-plugin-tailwindcss": "0.6.11", + "sass": "^1.87.0", + "start-server-and-test": "^2.0.11", + "tailwindcss": "3.4.14", + "ts-proto": "^2.7.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login/postcss.config.cjs b/login/apps/login/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/login/apps/login/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/login/apps/login/prettier.config.mjs b/login/apps/login/prettier.config.mjs new file mode 100644 index 0000000000..6df557c2fd --- /dev/null +++ b/login/apps/login/prettier.config.mjs @@ -0,0 +1 @@ +export { default } from "@zitadel/prettier-config"; diff --git a/login/apps/login/public/checkbox.svg b/login/apps/login/public/checkbox.svg new file mode 100644 index 0000000000..94a3298ae6 --- /dev/null +++ b/login/apps/login/public/checkbox.svg @@ -0,0 +1 @@ + diff --git a/login/apps/login/public/favicon.ico b/login/apps/login/public/favicon.ico new file mode 100644 index 0000000000..a901eddc34 Binary files /dev/null and b/login/apps/login/public/favicon.ico differ diff --git a/login/apps/login/public/favicon/android-chrome-192x192.png b/login/apps/login/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000..f22bd442e6 Binary files /dev/null and b/login/apps/login/public/favicon/android-chrome-192x192.png differ diff --git a/login/apps/login/public/favicon/android-chrome-512x512.png b/login/apps/login/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000..6987ed11b4 Binary files /dev/null and b/login/apps/login/public/favicon/android-chrome-512x512.png differ diff --git a/login/apps/login/public/favicon/apple-touch-icon.png b/login/apps/login/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000..4816102015 Binary files /dev/null and b/login/apps/login/public/favicon/apple-touch-icon.png differ diff --git a/login/apps/login/public/favicon/browserconfig.xml b/login/apps/login/public/favicon/browserconfig.xml new file mode 100644 index 0000000000..75efb24254 --- /dev/null +++ b/login/apps/login/public/favicon/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #000000 + + + diff --git a/login/apps/login/public/favicon/favicon-16x16.png b/login/apps/login/public/favicon/favicon-16x16.png new file mode 100644 index 0000000000..4f45702ca9 Binary files /dev/null and b/login/apps/login/public/favicon/favicon-16x16.png differ diff --git a/login/apps/login/public/favicon/favicon-32x32.png b/login/apps/login/public/favicon/favicon-32x32.png new file mode 100644 index 0000000000..a598da05eb Binary files /dev/null and b/login/apps/login/public/favicon/favicon-32x32.png differ diff --git a/login/apps/login/public/favicon/favicon.ico b/login/apps/login/public/favicon/favicon.ico new file mode 100644 index 0000000000..af98450595 Binary files /dev/null and b/login/apps/login/public/favicon/favicon.ico differ diff --git a/login/apps/login/public/favicon/mstile-150x150.png b/login/apps/login/public/favicon/mstile-150x150.png new file mode 100644 index 0000000000..ab518480e6 Binary files /dev/null and b/login/apps/login/public/favicon/mstile-150x150.png differ diff --git a/login/apps/login/public/favicon/site.webmanifest b/login/apps/login/public/favicon/site.webmanifest new file mode 100644 index 0000000000..567c2c6549 --- /dev/null +++ b/login/apps/login/public/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "ZITADEL Login", + "short_name": "ZITADEL Login", + "icons": [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone" +} diff --git a/login/apps/login/public/grid-dark.svg b/login/apps/login/public/grid-dark.svg new file mode 100644 index 0000000000..d467ad6de0 --- /dev/null +++ b/login/apps/login/public/grid-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/login/apps/login/public/grid-light.svg b/login/apps/login/public/grid-light.svg new file mode 100644 index 0000000000..114c1186fe --- /dev/null +++ b/login/apps/login/public/grid-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg b/login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg new file mode 100644 index 0000000000..4a4e8be71b --- /dev/null +++ b/login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg b/login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg new file mode 100644 index 0000000000..33ea6b583b --- /dev/null +++ b/login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/public/zitadel-logo-dark.svg b/login/apps/login/public/zitadel-logo-dark.svg new file mode 100644 index 0000000000..6dcfe06e6d --- /dev/null +++ b/login/apps/login/public/zitadel-logo-dark.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/public/zitadel-logo-light.svg b/login/apps/login/public/zitadel-logo-light.svg new file mode 100644 index 0000000000..d48a5eeb94 --- /dev/null +++ b/login/apps/login/public/zitadel-logo-light.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/login/apps/login/readme.md b/login/apps/login/readme.md new file mode 100644 index 0000000000..ca7070a901 --- /dev/null +++ b/login/apps/login/readme.md @@ -0,0 +1,394 @@ +# ZITADEL Login UI + +This is going to be our next UI for the hosted login. It's based on Next.js 13 and its introduced `app/` directory. + +## Flow Diagram + +This diagram shows the available pages and flows. + +> Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +### /loginname + +This page shows a loginname field and Identity Providers to login or register. +If `loginSettings(org?).allowRegister` is `true`, it also shows a link to jump to /register + +/loginame + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getLegalAndSupportSettings(org?)` +- `getIdentityProviders(org?)` +- `getBrandingSettings(org?)` +- `getActiveIdentityProviders(org?)` +- `startIdentityProviderFlow` +- `listUsers(org?)` +- `listAuthenticationMethodTypes` +- `getOrgsByDomain` +- `createSession()` +- `getSession()` + +After a loginname is entered, a `listUsers` request is made using the loginName query to identify already registered users. + +**USER FOUND:** If only one user is found, we query `listAuthenticationMethodTypes` to identify future steps. +If no authentication methods are found, we render an error stating: _User has no available authentication methods._ (exception see below.) +Now if only one method is found, we continue with the corresponding step (/password, /passkey). +If multiple methods are set, we prefer passkeys over any other method, so we redirect to /passkey, second option is IDP, and third is password. +If password is the next step, we check `loginSettings.passkeysType` for PasskeysType.ALLOWED, and prompt the user to setup passkeys afterwards. + +**NO USER FOUND:** If no user is found, we check whether registering is allowed using `loginSettings.allowRegister`. +If `loginSettings?.allowUsernamePassword` is not allowed we continue to check for available IDPs. If a single IDP is available, we directly redirect the user to signup. + +If no single IDP is set, we check for `loginSettings.allowUsernamePassword` and if no organization is set as context, we check whether we can discover a organization from the loginname of the user (using: `getOrgsByDomain`). Then if an organization is found, we check whether domainDiscovery is allowed on it and redirect the user to /register page including the discovered domain or without. + +If no previous condition is met we throw an error stating the user was not found. + +**EXCEPTIONS:** If the outcome after this order produces a no authentication methods found, or user not found, we check whether `loginSettings?.ignoreUnknownUsernames` is set to `true` as in this case we redirect to the /password page regardless (to prevent username guessing). + +> NOTE: This page at this stage beeing ignores local sessions and executes a reauthentication. This is a feature which is not implemented yet. + +> NOTE: We ignore `loginSettings.allowExternalIdp` as the information whether IDPs are available comes as response from `getActiveIdentityProviders(org?)`. If a user has a cookie for the same loginname, a new session is created regardless and overwrites the old session. The old session is not deleted from the login as for now. + +> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) + +### /password + +This page shows a password field to hydrate the current session with password as a factor. +Below the password field, a reset password link is shown which allows to send a reset email. + +/password + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getBrandingSettings(org?)` +- `listAuthenticationMethodTypes` +- `getSession()` +- `updateSession()` +- `listUsers()` +- `getUserById()` + +**MFA AVAILABLE:** After the password has been submitted, additional authentication methods are loaded. +If the user has set up an additional **single** second factor, it is redirected to add the next factor. Depending on the available method he is redirected to `/otp/time-based`,`/otp/sms?`, `/otp/email?` or `/u2f?`. If the user has multiple second factors, he is redirected to `/mfa` to select his preferred method to continue. + +**NO MFA, USER STATE INITIAL** If the user has no MFA methods and is in an initial state, we redirect to `/password/change` where a new password can be set. + +**NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor. + +**PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType == PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped. + +If none of the previous conditions apply, we continue to sign in. + +> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f methods or passkeys. The check whether a user should be redirected to one of the pages `/passkey` or `/u2f`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) + +### /password/change + +This page allows to change the password. It is used after a user is in an initial state and is required to change the password, or it can be directly invoked with an active session. + +/password/change + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getPasswordComplexitySettings(user?)` +- `getBrandingSettings(org?)` +- `getSession()` +- `setPassword()` + +> NOTE: The request to change the password is using the session of the user itself not the service user, therefore no code is required. + +### /password/set + +This page allows to set a password. It is used after a user has requested to reset the password on the `/password` page. + +/password/set + +Requests to the APIs made: + +- `getLoginSettings(org?)` +- `getPasswordComplexitySettings(user?)` +- `getBrandingSettings(org?)` +- `getUserByID()` +- `setPassword()` + +The page allows to enter a code or be invoked directly from a email link which prefills the code. The user can enter a new password and submit. + +### /otp/[method] + +This page shows a code field to check an otp method. The session of the user is then hydrated with the respective factor. Supported methods are `time-based`, `sms` and `email`. + +/otp/[method] + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `updateSession()` + +If `email` or `sms` is requested as method, the current session of the user is updated to request the challenge. This will trigger an email or sms which can be entered in the code field. +The `time-based` (TOTP) method does not require a trigger, therefore no `updateSession()` is performed and no resendLink under the code field is shown. + +The submission of the code updates the session and continues to sign in the user. + +### /u2f + +This page requests a webAuthN challenge for the user and updates the session afterwards. + +/u2f + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `updateSession()` + +When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.DISCOURAGED` as this will request the webAuthN method as second factor and not as primary method. +After updating the session, the user is **always** signed in. :warning: required as this page is a follow up for setting up a u2f method. + +### /passkey + +This page requests a webAuthN challenge for the user and updates the session afterwards. +It is invoked directly after setting up a passkey `/passkey/set` or when loggin in a user after `/loginname`. + +/passkey + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `updateSession()` + +When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.REQUIRED` as this will request the webAuthN method as primary method to login. +After updating the session, the user is **always** signed in. :warning: required as this page is a follow up for setting up a passkey + +> NOTE: This page currently does not check whether a user contains passkeys. If this method is not available, this page should not be used. + +### /mfa/set + +This page loads login settings and the authentication methods for a user and shows setup options. + +/mfa/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getLoginSettings(user.org)` :warning: context taken from session +- `getSession()` +- `listAuthenticationMethodTypes()` +- `getUserByID()` + +If a user has already setup a certain method, a checkbox is shown alongside the button and the button is disabled. +OTP Email and OTP SMS only show up if the user has verified email or phone. +If the user chooses a method he is redirected to one of `/otp/time-based/set`, `/u2f/set`, `/otp/email/set`, or `/otp/sms/set`. +At the moment, U2F methods are hidden if a method is already added on the users resource. Reasoning is that the page should only be invoked for prompts. A self service page which shows up multiple u2f factors is implemented at a later stage. + +> NOTE: The session and therefore the user factor defines which login settings are checked for available options. + +> NOTE: `listAuthenticationMethodTypes()` does not consider different domains for u2f or passkeys. The check whether a user should be redirected to one of the pages `/passkey/set` or `/u2f/set`, should be extended to use a domain filter (https://github.com/zitadel/zitadel/issues/8615) + +### /passkey/set + +This page sets a passkey method for a user. This page can be either enforced, or optional depending on the Login Settings. + + +/passkey/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `createPasskeyRegistrationLink()` TODO: check if this can be used with the session token (mfa required (AUTHZ-Kl3p0)) +- `registerPasskey()` +- `verifyPasskey()` + +If the loginname decides to redirect the user to this page, a button to skip appears which will sign the user in afterwards. +After a passkey is registered, we redirect the user to `/passkey` to verify it again and sign in with the new method. The `createPasskeyRegistrationLink()` uses the token of the session which is determined by the flow. + +> NOTE: this page allows passkeys to be created only if the current session is valid (self service), or no authentication method is set (register). TODO: to be implemented. + +> NOTE: Redirecting the user to `/passkey` will not be required in future and the currently used session will be hydrated directly after registering. (https://github.com/zitadel/zitadel/issues/8611) + +### /otp/time-based/set + +This page registers a time based OTP method for a user. + +/otp/time-based/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `registerTOTP()` +- `verifyTOTP()` + +After the setup is done, the user is redirected to verify the TOTP method on `/otp/time-based`. + +> NOTE: Redirecting the user to `/otp/time-based` will not be required in future and the currently used session will be hydrated directly. (https://github.com/zitadel/zitadel/issues/8611) + +### /otp/email/set /otp/sms/set + +This page registers either an Email OTP method or SMS OTP method for a user. + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `addOTPEmail()` / `addOTPSMS()` + +This page directly calls `addOTPEmail()` or `addOTPSMS()` when invoked and shows a success message. +Right afterwards, redirects to verify the method. + +### /u2f/set + +This page registers a U2F method for a user. + +/u2f/set + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getSession()` +- `registerU2F()` :warning: TODO: check if this can be used with the session token (mfa required (AUTHZ-Kl3p0)) +- `verifyU2FRegistration()` + +After a u2f method is registered, we redirect the user to `/passkey` to verify it again and sign in with the new method. The `createPasskeyRegistrationLink()` uses the token of the session which is determined by the flow. + +> NOTE: Redirecting the user to `/passkey` will not be required in future and the currently used session will be hydrated directly after registering. (https://github.com/zitadel/zitadel/issues/8611) + +### /register + +This page shows a register page, which gets firstname and lastname of a user as well as the email. It offers to setup a user, using password or passkeys. + +/register + +register with password + +Requests to the APIs made: + +- `listOrganizations()` :warning: TODO: determine the default organization if no context is set +- `getLegalAndSupportSettings(org)` +- `getPasswordComplexitySettings()` +- `getBrandingSettings()` +- `addHumanUser()` +- `createSession()` +- `getSession()` + +To register a user, the organization where the resource will be created is determined first. If no context is provided via url, we fall back to the default organization of the instance. + +**PASSWORD:** If a password is set, the user is created as a resource, then a session using the password check is created immediately. After creating the session, the user is directly logged in and eventually redirected back to the application. + +**PASSKEY:** If passkey is selected, the user is created as a resource first, then a session using the userId is created immediately. This session does not yet contain a check, we therefore redirect the user to setup a passkey at `/passkey/set`. As the passkey set page verifies the passkey right afterwards, the process ends with a signed in user. + +> NOTE: https://github.com/zitadel/zitadel/issues/8616 to determine the default organization of an instance must be implemented in order to correctly use the legal-, login-, branding- and complexitysettings. + +> NOTE: TODO: check which methods are allowed in the login settings, loginSettings.allowUsernamePassword / check for passkey + +### /idp + +This page doubles as /loginname but limits it to choose from IDPs + +/idp + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getActiveIdentityProviders(org?)` +- `startIdentityProviderFlow()` + +### /idp/[method]/success /idp/[method]/failure + +Both /success and /failure pages are designed to intercept the responses from the IDPs and decide on how to continue with the process. + +### /verify + +This page verifies the email to be valid. It page of the login can also be invoked without an active session. +The context of the user is taken from the url and is set in the email template. + +/accounts + +Requests to the APIs made: + +- `getBrandingSettings(org?)` +- `getLoginSettings(org?)` +- `verifyEmail()` + +If the page is invoked with an active session (right after a register with password), the user is signed in or redirected to the loginname if no context is known. + +> NOTE: This page will be extended to support invitations. In such case, authentication methods of the user are loaded and if none available, shown as possible next step (`/passkey/set`, `password/set`). + +### /accounts + +This page shows an overview of all current sessions. +Sessions with invalid token show a red dot on the right side, Valid session a green dot, and its last verified date. + +/accounts + +This page is a starting point for self management, reauthentication, or can be used to clear local sessions. +This page is also shown if used with OIDC and `prompt: select_account`. + +On all pages, where the current user is shown, you can jump to this page. This way, a session can quickly be reused if valid. + +jump to accounts + +### /signedin + +This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest. From here device authorization flows are completed. It checks if the requestId param of starts with `device_` and then executes the `authorizeOrDenyDeviceAuthorization` command. + +/signedin + +In future, self service options to jump to are shown below, like: + +- change password +- setup passkeys +- setup mfa +- change profile +- logout + +> NOTE: This page has to be explicitly enabled or act as a fallback if no default redirect is set. + +## Currently NOT Supported + +- forceMFA on login settings is not checked for IDPs + +Also note that IDP logins are considered as valid MFA. An additional MFA check will be implemented in future if enforced. diff --git a/login/apps/login/screenshots/accounts.png b/login/apps/login/screenshots/accounts.png new file mode 100644 index 0000000000..a8591141c6 Binary files /dev/null and b/login/apps/login/screenshots/accounts.png differ diff --git a/login/apps/login/screenshots/accounts_jumpto.png b/login/apps/login/screenshots/accounts_jumpto.png new file mode 100644 index 0000000000..0fd126bf4c Binary files /dev/null and b/login/apps/login/screenshots/accounts_jumpto.png differ diff --git a/login/apps/login/screenshots/collage.png b/login/apps/login/screenshots/collage.png new file mode 100644 index 0000000000..9d5a9c35c8 Binary files /dev/null and b/login/apps/login/screenshots/collage.png differ diff --git a/login/apps/login/screenshots/idp.png b/login/apps/login/screenshots/idp.png new file mode 100644 index 0000000000..9bf58c69b0 Binary files /dev/null and b/login/apps/login/screenshots/idp.png differ diff --git a/login/apps/login/screenshots/loginname.png b/login/apps/login/screenshots/loginname.png new file mode 100644 index 0000000000..342e60799e Binary files /dev/null and b/login/apps/login/screenshots/loginname.png differ diff --git a/login/apps/login/screenshots/mfa.png b/login/apps/login/screenshots/mfa.png new file mode 100644 index 0000000000..1fd73f205c Binary files /dev/null and b/login/apps/login/screenshots/mfa.png differ diff --git a/login/apps/login/screenshots/mfaset.png b/login/apps/login/screenshots/mfaset.png new file mode 100644 index 0000000000..c00ee4edf5 Binary files /dev/null and b/login/apps/login/screenshots/mfaset.png differ diff --git a/login/apps/login/screenshots/otp.png b/login/apps/login/screenshots/otp.png new file mode 100644 index 0000000000..3818a5ad5f Binary files /dev/null and b/login/apps/login/screenshots/otp.png differ diff --git a/login/apps/login/screenshots/otpset.png b/login/apps/login/screenshots/otpset.png new file mode 100644 index 0000000000..f75c2154c7 Binary files /dev/null and b/login/apps/login/screenshots/otpset.png differ diff --git a/login/apps/login/screenshots/passkey.png b/login/apps/login/screenshots/passkey.png new file mode 100644 index 0000000000..7a5686c736 Binary files /dev/null and b/login/apps/login/screenshots/passkey.png differ diff --git a/login/apps/login/screenshots/password.png b/login/apps/login/screenshots/password.png new file mode 100644 index 0000000000..05cf8747bb Binary files /dev/null and b/login/apps/login/screenshots/password.png differ diff --git a/login/apps/login/screenshots/password_change.png b/login/apps/login/screenshots/password_change.png new file mode 100644 index 0000000000..183de6df34 Binary files /dev/null and b/login/apps/login/screenshots/password_change.png differ diff --git a/login/apps/login/screenshots/password_set.png b/login/apps/login/screenshots/password_set.png new file mode 100644 index 0000000000..15b5ff49ad Binary files /dev/null and b/login/apps/login/screenshots/password_set.png differ diff --git a/login/apps/login/screenshots/register.png b/login/apps/login/screenshots/register.png new file mode 100644 index 0000000000..ba9f6951d8 Binary files /dev/null and b/login/apps/login/screenshots/register.png differ diff --git a/login/apps/login/screenshots/register_password.png b/login/apps/login/screenshots/register_password.png new file mode 100644 index 0000000000..31515bda9a Binary files /dev/null and b/login/apps/login/screenshots/register_password.png differ diff --git a/login/apps/login/screenshots/signedin.png b/login/apps/login/screenshots/signedin.png new file mode 100644 index 0000000000..f96ea1721f Binary files /dev/null and b/login/apps/login/screenshots/signedin.png differ diff --git a/login/apps/login/screenshots/u2f.png b/login/apps/login/screenshots/u2f.png new file mode 100644 index 0000000000..6b8eca087d Binary files /dev/null and b/login/apps/login/screenshots/u2f.png differ diff --git a/login/apps/login/screenshots/u2fset.png b/login/apps/login/screenshots/u2fset.png new file mode 100644 index 0000000000..37115548a5 Binary files /dev/null and b/login/apps/login/screenshots/u2fset.png differ diff --git a/login/apps/login/screenshots/verify.png b/login/apps/login/screenshots/verify.png new file mode 100644 index 0000000000..c13e6a3a88 Binary files /dev/null and b/login/apps/login/screenshots/verify.png differ diff --git a/login/apps/login/src/app/(login)/accounts/page.tsx b/login/apps/login/src/app/(login)/accounts/page.tsx new file mode 100644 index 0000000000..32c88d17bf --- /dev/null +++ b/login/apps/login/src/app/(login)/accounts/page.tsx @@ -0,0 +1,91 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SessionsList } from "@/components/sessions-list"; +import { getAllSessionCookieIds } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + listSessions, +} from "@/lib/zitadel"; +import { UserPlusIcon } from "@heroicons/react/24/outline"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; + +async function loadSessions({ serviceUrl }: { serviceUrl: string }) { + const ids: (string | undefined)[] = await getAllSessionCookieIds(); + + if (ids && ids.length) { + const response = await listSessions({ + serviceUrl, + ids: ids.filter((id) => !!id) as string[], + }); + return response?.sessions ?? []; + } else { + console.info("No session cookie found."); + return []; + } +} + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "accounts" }); + + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + let sessions = await loadSessions({ serviceUrl }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const params = new URLSearchParams(); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization) { + params.append("organization", organization); + } + + return ( + +
+

{t("title")}

+

{t("description")}

+ +
+ + +
+
+ +
+ {t("addAnother")} +
+ +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/authenticator/set/page.tsx b/login/apps/login/src/app/(login)/authenticator/set/page.tsx new file mode 100644 index 0000000000..95b89af92d --- /dev/null +++ b/login/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -0,0 +1,209 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { ChooseAuthenticatorToSetup } from "@/components/choose-authenticator-to-setup"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { checkUserVerification } from "@/lib/verify-helper"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "authenticator" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, requestId, organization, sessionId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionWithData = sessionId + ? await loadSessionById(sessionId, organization) + : await loadSessionByLoginname(loginName, organization); + + async function getAuthMethodsAndUser( + serviceUrl: string, + + session?: Session, + ) { + const userId = session?.factors?.user?.id; + + if (!userId) { + throw Error("Could not get user id from session"); + } + + return listAuthenticationMethodTypes({ + serviceUrl, + userId, + }).then((methods) => { + return getUserByID({ serviceUrl, userId }).then((user) => { + const humanUser = + user.user?.type.case === "human" ? user.user?.type.value : undefined; + + return { + factors: session?.factors, + authMethods: methods.authMethodTypes ?? [], + phoneVerified: humanUser?.phone?.isVerified ?? false, + emailVerified: humanUser?.email?.isVerified ?? false, + expirationDate: session?.expirationDate, + }; + }); + }); + } + + async function loadSessionByLoginname( + loginName?: string, + organization?: string, + ) { + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + return getAuthMethodsAndUser(serviceUrl, session); + }); + } + + async function loadSessionById(sessionId: string, organization?: string) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((sessionResponse) => { + return getAuthMethodsAndUser(serviceUrl, sessionResponse.session); + }); + } + + if ( + !sessionWithData || + !sessionWithData.factors || + !sessionWithData.factors.user + ) { + return {tError("unknownContext")}; + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: sessionWithData.factors.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: sessionWithData.factors.user?.organizationId, + }); + + // check if user was verified recently + const isUserVerified = await checkUserVerification( + sessionWithData.factors.user?.id, + ); + + if (!isUserVerified) { + const params = new URLSearchParams({ + loginName: sessionWithData.factors.user.loginName as string, + invite: "true", + send: "true", // set this to true to request a new code immediately + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || sessionWithData.factors.user.organizationId) { + params.append( + "organization", + organization ?? (sessionWithData.factors.user.organizationId as string), + ); + } + + redirect(`/verify?` + params); + } + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: sessionWithData.factors?.user?.organizationId, + linking_allowed: true, + }).then((resp) => { + return resp.identityProviders; + }); + + const params = new URLSearchParams({ + initial: "true", // defines that a code is not required and is therefore not shown in the UI + }); + + if (sessionWithData.factors?.user?.loginName) { + params.set("loginName", sessionWithData.factors?.user?.loginName); + } + + if (sessionWithData.factors?.user?.organizationId) { + params.set("organization", sessionWithData.factors?.user?.organizationId); + } + + if (requestId) { + params.set("requestId", requestId); + } + + return ( + +
+

{t("title")}

+ +

{t("description")}

+ + + + {loginSettings && ( + + )} + + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( + <> +
+

{t("linkWithIDP")}

+
+ + + + )} + +
+ + +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/device/consent/page.tsx b/login/apps/login/src/app/(login)/device/consent/page.tsx new file mode 100644 index 0000000000..75676cd7ff --- /dev/null +++ b/login/apps/login/src/app/(login)/device/consent/page.tsx @@ -0,0 +1,89 @@ +import { ConsentScreen } from "@/components/consent"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + getDeviceAuthorizationRequest, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale }); + + const userCode = searchParams?.user_code; + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + if (!userCode || !requestId) { + return
{t("error.noUserCode")}
; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); + + if (!deviceAuthorizationRequest) { + return
{t("error.noDeviceRequest")}
; + } + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const params = new URLSearchParams(); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization) { + params.append("organization", organization); + } + + return ( + +
+

+ {t("device.request.title", { + appName: deviceAuthorizationRequest?.appName, + })} +

+ +

+ {t("device.request.description", { + appName: deviceAuthorizationRequest?.appName, + })} +

+ + +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/device/page.tsx b/login/apps/login/src/app/(login)/device/page.tsx new file mode 100644 index 0000000000..1b262c41fc --- /dev/null +++ b/login/apps/login/src/app/(login)/device/page.tsx @@ -0,0 +1,46 @@ +import { DeviceCodeForm } from "@/components/device-code-form"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "device" }); + + const userCode = searchParams?.user_code; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

{t("usercode.title")}

+

{t("usercode.description")}

+ +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/error.tsx b/login/apps/login/src/app/(login)/error.tsx new file mode 100644 index 0000000000..bee6516a59 --- /dev/null +++ b/login/apps/login/src/app/(login)/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Boundary } from "@/components/boundary"; +import { Button } from "@/components/button"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; + +export default function Error({ error, reset }: any) { + useEffect(() => { + console.log("logging error:", error); + }, [error]); + + const t = useTranslations("error"); + + return ( + +
+
+ Error: {error?.message} +
+
+ +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx b/login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx new file mode 100644 index 0000000000..de6ad858d9 --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx @@ -0,0 +1,103 @@ +import { Alert, AlertType } from "@/components/alert"; +import { ChooseAuthenticatorToLogin } from "@/components/choose-authenticator-to-login"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getLoginSettings, + getUserByID, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + const { organization, userId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + let authMethods: AuthenticationMethodType[] = []; + let user: User | undefined = undefined; + let human: HumanUser | undefined = undefined; + + const params = new URLSearchParams({}); + if (organization) { + params.set("organization", organization); + } + if (userId) { + params.set("userId", userId); + } + + if (userId) { + const userResponse = await getUserByID({ + serviceUrl, + userId, + }); + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + + if (user?.preferredLoginName) { + params.set("loginName", user.preferredLoginName); + } + } + + const authMethodsResponse = await listAuthenticationMethodTypes({ + serviceUrl, + userId, + }); + if (authMethodsResponse.authMethodTypes) { + authMethods = authMethodsResponse.authMethodTypes; + } + } + + return ( + +
+

{t("loginError.title")}

+ {t("loginError.description")} + + {userId && authMethods.length && ( + <> + {user && human && ( + + )} + + + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx b/login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx new file mode 100644 index 0000000000..bfbde8b252 --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -0,0 +1,338 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { IdpSignin } from "@/components/idp-signin"; +import { completeIDP } from "@/components/idps/pages/complete-idp"; +import { linkingFailed } from "@/components/idps/pages/linking-failed"; +import { linkingSuccess } from "@/components/idps/pages/linking-success"; +import { loginFailed } from "@/components/idps/pages/login-failed"; +import { loginSuccess } from "@/components/idps/pages/login-success"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + addHuman, + addIDPLink, + getBrandingSettings, + getDefaultOrg, + getIDPByID, + getLoginSettings, + getOrgsByDomain, + listUsers, + retrieveIDPIntent, + updateHuman, +} from "@/lib/zitadel"; +import { ConnectError, create } from "@zitadel/client"; +import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { + AddHumanUserRequest, + AddHumanUserRequestSchema, + UpdateHumanUserRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; + +async function resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, +}: { + organization?: string; + addHumanUser?: { username?: string }; + serviceUrl: string; +}): Promise { + if (organization) return organization; + + if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) { + const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); + const suffix = matched?.[1] ?? ""; + + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = + orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + if (orgToCheckForDiscovery) { + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + return orgToCheckForDiscovery; + } + } + } + return undefined; +} + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + let { id, token, requestId, organization, link } = searchParams; + const { provider } = params; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + + if (!provider || !id || !token) { + return loginFailed(branding, "IDP context missing"); + } + + const intent = await retrieveIDPIntent({ + serviceUrl, + id, + token, + }); + + const { idpInformation, userId } = intent; + let { addHumanUser } = intent; + + if (!idpInformation) { + return loginFailed(branding, "IDP information missing"); + } + + const idp = await getIDPByID({ + serviceUrl, + id: idpInformation.idpId, + }); + + const options = idp?.config?.options; + + if (!idp) { + throw new Error("IDP not found"); + } + + // sign in user. If user should be linked continue + if (userId && !link) { + // if auto update is enabled, we will update the user with the new information + if (options?.isAutoUpdate && addHumanUser) { + try { + await updateHuman({ + serviceUrl, + request: create(UpdateHumanUserRequestSchema, { + userId: userId, + profile: addHumanUser.profile, + email: addHumanUser.email, + phone: addHumanUser.phone, + }), + }); + } catch (error: unknown) { + // Log the error and continue with the login process + console.warn("An error occurred while updating the user:", error); + } + } + + return loginSuccess( + userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + + if (link) { + if (!options?.isLinkingAllowed) { + // linking was probably disallowed since the invitation was created + return linkingFailed(branding, "Linking is no longer allowed"); + } + + let idpLink; + try { + idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: idpInformation.idpId, + userId: idpInformation.userId, + userName: idpInformation.userName, + }, + userId, + }); + } catch (error) { + console.error(error); + return linkingFailed(branding); + } + + if (!idpLink) { + return linkingFailed(branding); + } else { + return linkingSuccess( + userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + } + + // search for potential user via username, then link + if (options?.autoLinking) { + let foundUser; + const email = addHumanUser?.email?.email; + + if (options.autoLinking === AutoLinkingOption.EMAIL && email) { + foundUser = await listUsers({ serviceUrl, email }).then((response) => { + return response.result ? response.result[0] : null; + }); + } else if (options.autoLinking === AutoLinkingOption.USERNAME) { + foundUser = await listUsers( + options.autoLinking === AutoLinkingOption.USERNAME + ? { serviceUrl, userName: idpInformation.userName } + : { serviceUrl, email }, + ).then((response) => { + return response.result ? response.result[0] : null; + }); + } else { + foundUser = await listUsers({ + serviceUrl, + userName: idpInformation.userName, + email, + }).then((response) => { + return response.result ? response.result[0] : null; + }); + } + + if (foundUser) { + let idpLink; + try { + idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: idpInformation.idpId, + userId: idpInformation.userId, + userName: idpInformation.userName, + }, + userId: foundUser.userId, + }); + } catch (error) { + console.error(error); + return linkingFailed(branding); + } + + if (!idpLink) { + return linkingFailed(branding); + } else { + return linkingSuccess( + foundUser.userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + } + } + + let newUser; + // automatic creation of a user is allowed and data is complete + if (options?.isAutoCreation && addHumanUser) { + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + let addHumanUserWithOrganization: AddHumanUserRequest; + if (orgToRegisterOn) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: orgToRegisterOn }, + }); + + addHumanUserWithOrganization = create(AddHumanUserRequestSchema, { + ...addHumanUser, + organization: organizationSchema, + }); + } else { + addHumanUserWithOrganization = create( + AddHumanUserRequestSchema, + addHumanUser, + ); + } + + try { + newUser = await addHuman({ + serviceUrl, + request: addHumanUserWithOrganization, + }); + } catch (error: unknown) { + console.error( + "An error occurred while creating the user:", + error, + addHumanUser, + ); + return loginFailed( + branding, + (error as ConnectError).message + ? (error as ConnectError).message + : "Could not create user", + ); + } + } else if (options?.isCreationAllowed) { + // if no user was found, we will create a new user manually / redirect to the registration page + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + if (orgToRegisterOn) { + branding = await getBrandingSettings({ + serviceUrl, + organization: orgToRegisterOn, + }); + } + + if (!orgToRegisterOn) { + return loginFailed(branding, "No organization found for registration"); + } + + return completeIDP({ + branding, + idpIntent: { idpIntentId: id, idpIntentToken: token }, + addHumanUser, + organization: orgToRegisterOn, + requestId, + idpUserId: idpInformation?.userId, + idpId: idpInformation?.idpId, + idpUserName: idpInformation?.userName, + }); + } + + if (newUser) { + return ( + +
+

{t("registerSuccess.title")}

+

{t("registerSuccess.description")}

+ +
+
+ ); + } + + // return login failed if no linking or creation is allowed and no user was found + return loginFailed(branding, "No user found"); +} diff --git a/login/apps/login/src/app/(login)/idp/page.tsx b/login/apps/login/src/app/(login)/idp/page.tsx new file mode 100644 index 0000000000..db492cb79c --- /dev/null +++ b/login/apps/login/src/app/(login)/idp/page.tsx @@ -0,0 +1,49 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getActiveIdentityProviders, getBrandingSettings } from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization, + }).then((resp) => { + return resp.identityProviders; + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

{t("title")}

+

{t("description")}

+ + {identityProviders && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/layout.tsx b/login/apps/login/src/app/(login)/layout.tsx new file mode 100644 index 0000000000..936c7e17e4 --- /dev/null +++ b/login/apps/login/src/app/(login)/layout.tsx @@ -0,0 +1,62 @@ +import "@/styles/globals.scss"; + +import { LanguageProvider } from "@/components/language-provider"; +import { LanguageSwitcher } from "@/components/language-switcher"; +import { Skeleton } from "@/components/skeleton"; +import { Theme } from "@/components/theme"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Analytics } from "@vercel/analytics/react"; +import { Lato } from "next/font/google"; +import { ReactNode, Suspense } from "react"; + +const lato = Lato({ + weight: ["400", "700", "900"], + subsets: ["latin"], +}); + +export default async function RootLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + + + + + +
+ +
+
+
+ +
+
+ + } + > + +
+
+ {children} +
+ + +
+
+
+
+
+
+ + + + ); +} diff --git a/login/apps/login/src/app/(login)/loginname/page.tsx b/login/apps/login/src/app/(login)/loginname/page.tsx new file mode 100644 index 0000000000..adb6ec0eef --- /dev/null +++ b/login/apps/login/src/app/(login)/loginname/page.tsx @@ -0,0 +1,89 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { UsernameForm } from "@/components/username-form"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getDefaultOrg, + getLoginSettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "loginname" }); + + const loginName = searchParams?.loginName; + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + const suffix = searchParams?.suffix; + const submit: boolean = searchParams?.submit === "true"; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const contextLoginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization ?? defaultOrganization, + }).then((resp) => { + return resp.identityProviders; + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

{t("title")}

+

{t("description")}

+ + + {identityProviders && loginSettings?.allowExternalIdp && ( + + )} + +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/logout/page.tsx b/login/apps/login/src/app/(login)/logout/page.tsx new file mode 100644 index 0000000000..3540845b12 --- /dev/null +++ b/login/apps/login/src/app/(login)/logout/page.tsx @@ -0,0 +1,84 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SessionsClearList } from "@/components/sessions-clear-list"; +import { getAllSessionCookieIds } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + listSessions, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +async function loadSessions({ serviceUrl }: { serviceUrl: string }) { + const ids: (string | undefined)[] = await getAllSessionCookieIds(); + + if (ids && ids.length) { + const response = await listSessions({ + serviceUrl, + ids: ids.filter((id) => !!id) as string[], + }); + return response?.sessions ?? []; + } else { + console.info("No session cookie found."); + return []; + } +} + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "logout" }); + + const organization = searchParams?.organization; + const postLogoutRedirectUri = searchParams?.post_logout_redirect_uri; + const logoutHint = searchParams?.logout_hint; + const UILocales = searchParams?.ui_locales; // TODO implement with new translation service + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + let sessions = await loadSessions({ serviceUrl }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const params = new URLSearchParams(); + + if (organization) { + params.append("organization", organization); + } + + return ( + +
+

{t("title")}

+

{t("description")}

+ +
+ +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/logout/success/page.tsx b/login/apps/login/src/app/(login)/logout/success/page.tsx new file mode 100644 index 0000000000..da98092852 --- /dev/null +++ b/login/apps/login/src/app/(login)/logout/success/page.tsx @@ -0,0 +1,41 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "logout" }); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { login_hint, organization } = searchParams; + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

{t("success.title")}

+

{t("success.description")}

+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/mfa/page.tsx b/login/apps/login/src/app/(login)/mfa/page.tsx new file mode 100644 index 0000000000..c65d6d3058 --- /dev/null +++ b/login/apps/login/src/app/(login)/mfa/page.tsx @@ -0,0 +1,127 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { ChooseSecondFactor } from "@/components/choose-second-factor"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getSession, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "mfa" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, requestId, organization, sessionId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadSessionByLoginname(serviceUrl, loginName, organization); + + async function loadSessionByLoginname( + serviceUrl: string, + loginName?: string, + organization?: string, + ) { + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + if (session && session.factors?.user?.id) { + return listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }).then((methods) => { + return { + factors: session?.factors, + authMethods: methods.authMethodTypes ?? [], + }; + }); + } + }); + } + + async function loadSessionById( + host: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session && response.session.factors?.user?.id) { + return listAuthenticationMethodTypes({ + serviceUrl, + userId: response.session.factors.user.id, + }).then((methods) => { + return { + factors: response.session?.factors, + authMethods: methods.authMethodTypes ?? [], + }; + }); + } + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

{t("verify.title")}

+ +

{t("verify.description")}

+ + {sessionFactors && ( + + )} + + {!(loginName || sessionId) && {tError("unknownContext")}} + + {sessionFactors ? ( + + ) : ( + {t("verify.noResults")} + )} + +
+ + +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/mfa/set/page.tsx b/login/apps/login/src/app/(login)/mfa/set/page.tsx new file mode 100644 index 0000000000..c7f2fa6599 --- /dev/null +++ b/login/apps/login/src/app/(login)/mfa/set/page.tsx @@ -0,0 +1,165 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { ChooseSecondFactorToSetup } from "@/components/choose-second-factor-to-setup"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey; + const valid = !!((validPassword || validPasskey) && stillValid); + + return { valid, verifiedAt }; +} + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "mfa" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, checkAfter, force, requestId, organization, sessionId } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionWithData = sessionId + ? await loadSessionById(sessionId, organization) + : await loadSessionByLoginname(loginName, organization); + + async function getAuthMethodsAndUser(session?: Session) { + const userId = session?.factors?.user?.id; + + if (!userId) { + throw Error("Could not get user id from session"); + } + + return listAuthenticationMethodTypes({ + serviceUrl, + userId, + }).then((methods) => { + return getUserByID({ serviceUrl, userId }).then((user) => { + const humanUser = + user.user?.type.case === "human" ? user.user?.type.value : undefined; + + return { + id: session.id, + factors: session?.factors, + authMethods: methods.authMethodTypes ?? [], + phoneVerified: humanUser?.phone?.isVerified ?? false, + emailVerified: humanUser?.email?.isVerified ?? false, + expirationDate: session?.expirationDate, + }; + }); + }); + } + + async function loadSessionByLoginname( + loginName?: string, + organization?: string, + ) { + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + return getAuthMethodsAndUser(session); + }); + } + + async function loadSessionById(sessionId: string, organization?: string) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((sessionResponse) => { + return getAuthMethodsAndUser(sessionResponse.session); + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: sessionWithData.factors?.user?.organizationId, + }); + + const { valid } = isSessionValid(sessionWithData); + + return ( + +
+

{t("set.title")}

+ +

{t("set.description")}

+ + {sessionWithData && ( + + )} + + {!(loginName || sessionId) && {tError("unknownContext")}} + + {!valid && {tError("sessionExpired")}} + + {isSessionValid(sessionWithData).valid && + loginSettings && + sessionWithData && + sessionWithData.factors?.user?.id && ( + + )} + +
+ + +
+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/otp/[method]/page.tsx b/login/apps/login/src/app/(login)/otp/[method]/page.tsx new file mode 100644 index 0000000000..ee58420c42 --- /dev/null +++ b/login/apps/login/src/app/(login)/otp/[method]/page.tsx @@ -0,0 +1,127 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LoginOTP } from "@/components/login-otp"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, +} from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "otp" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const { + loginName, // send from password page + userId, // send from email link + requestId, + sessionId, + organization, + code, + submit, + } = searchParams; + + const { method } = params; + + const session = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + async function loadSessionById( + host: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + // email links do not come with organization, thus we need to use the session's organization + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? session?.factors?.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? session?.factors?.user?.organizationId, + }); + + return ( + +
+

{t("verify.title")}

+ {method === "time-based" && ( +

{t("verify.totpDescription")}

+ )} + {method === "sms" && ( +

{t("verify.smsDescription")}

+ )} + {method === "email" && ( +

{t("verify.emailDescription")}

+ )} + + {!session && ( +
+ {tError("unknownContext")} +
+ )} + + {session && ( + + )} + + {method && session && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/otp/[method]/set/page.tsx b/login/apps/login/src/app/(login)/otp/[method]/set/page.tsx new file mode 100644 index 0000000000..d3fc4c89f7 --- /dev/null +++ b/login/apps/login/src/app/(login)/otp/[method]/set/page.tsx @@ -0,0 +1,198 @@ +import { Alert } from "@/components/alert"; +import { BackButton } from "@/components/back-button"; +import { Button, ButtonVariants } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { TotpRegister } from "@/components/totp-register"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + addOTPEmail, + addOTPSMS, + getBrandingSettings, + getLoginSettings, + registerTOTP, +} from "@/lib/zitadel"; +import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "otp" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, organization, sessionId, requestId, checkAfter } = + searchParams; + const { method } = params; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const session = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + let totpResponse: RegisterTOTPResponse | undefined, error: Error | undefined; + if (session && session.factors?.user?.id) { + if (method === "time-based") { + await registerTOTP({ + serviceUrl, + userId: session.factors.user.id, + }) + .then((resp) => { + if (resp) { + totpResponse = resp; + } + }) + .catch((err) => { + error = err; + }); + } else if (method === "sms") { + // does not work + await addOTPSMS({ + serviceUrl, + userId: session.factors.user.id, + }).catch((error) => { + error = new Error("Could not add OTP via SMS"); + }); + } else if (method === "email") { + // works + await addOTPEmail({ + serviceUrl, + userId: session.factors.user.id, + }).catch((error) => { + error = new Error("Could not add OTP via Email"); + }); + } else { + throw new Error("Invalid method"); + } + } else { + throw new Error("No session found"); + } + + const paramsToContinue = new URLSearchParams({}); + let urlToContinue = "/accounts"; + + if (sessionId) { + paramsToContinue.append("sessionId", sessionId); + } + if (loginName) { + paramsToContinue.append("loginName", loginName); + } + if (organization) { + paramsToContinue.append("organization", organization); + } + + if (checkAfter) { + if (requestId) { + paramsToContinue.append("requestId", requestId); + } + urlToContinue = `/otp/${method}?` + paramsToContinue; + // immediately check the OTP on the next page if sms or email was set up + if (["email", "sms"].includes(method)) { + return redirect(urlToContinue); + } + } else if (requestId && sessionId) { + if (requestId) { + paramsToContinue.append("authRequest", requestId); + } + urlToContinue = `/login?` + paramsToContinue; + } else if (loginName) { + if (requestId) { + paramsToContinue.append("requestId", requestId); + } + urlToContinue = `/signedin?` + paramsToContinue; + } + + return ( + +
+

{t("set.title")}

+ {!session && ( +
+ {tError("unknownContext")} +
+ )} + + {error && ( +
+ {error?.message} +
+ )} + + {session && ( + + )} + + {totpResponse && "uri" in totpResponse && "secret" in totpResponse ? ( + <> +

{t("set.totpRegisterDescription")}

+
+ +
{" "} + + ) : ( + <> +

+ {method === "email" + ? "Code via email was successfully added." + : method === "sms" + ? "Code via SMS was successfully added." + : ""} +

+ +
+ + + + + + +
+ + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/page.tsx b/login/apps/login/src/app/(login)/page.tsx new file mode 100644 index 0000000000..f1fce50f90 --- /dev/null +++ b/login/apps/login/src/app/(login)/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default function Page() { + // automatically redirect to loginname + if (process.env.DEBUG !== "true") { + redirect("/loginname"); + } +} diff --git a/login/apps/login/src/app/(login)/passkey/page.tsx b/login/apps/login/src/app/(login)/passkey/page.tsx new file mode 100644 index 0000000000..e24585e7e0 --- /dev/null +++ b/login/apps/login/src/app/(login)/passkey/page.tsx @@ -0,0 +1,84 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LoginPasskey } from "@/components/login-passkey"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getSession } from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "passkey" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, altPassword, requestId, organization, sessionId } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

{t("verify.title")}

+ + {sessionFactors && ( + + )} +

{t("verify.description")}

+ + {!(loginName || sessionId) && {tError("unknownContext")}} + + {(loginName || sessionId) && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/passkey/set/page.tsx b/login/apps/login/src/app/(login)/passkey/set/page.tsx new file mode 100644 index 0000000000..dad3749fd7 --- /dev/null +++ b/login/apps/login/src/app/(login)/passkey/set/page.tsx @@ -0,0 +1,82 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterPasskey } from "@/components/register-passkey"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings } from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "passkey" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, prompt, organization, requestId, userId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const session = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

{t("set.title")}

+ + {session && ( + + )} +

{t("set.description")}

+ + + + {t("set.info.description")} + + {t("set.info.link")} + + + + + {!session && ( +
+ {tError("unknownContext")} +
+ )} + + {session?.id && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/password/change/page.tsx b/login/apps/login/src/app/(login)/password/change/page.tsx new file mode 100644 index 0000000000..05f8cd6a10 --- /dev/null +++ b/login/apps/login/src/app/(login)/password/change/page.tsx @@ -0,0 +1,95 @@ +import { Alert } from "@/components/alert"; +import { ChangePasswordForm } from "@/components/change-password-form"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "password" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, organization, requestId } = searchParams; + + // also allow no session to be found (ignoreUnkownUsername) + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const passwordComplexity = await getPasswordComplexitySettings({ + serviceUrl, + organization: sessionFactors?.factors?.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: sessionFactors?.factors?.user?.organizationId, + }); + + return ( + +
+

+ {sessionFactors?.factors?.user?.displayName ?? t("change.title")} +

+

{t("change.description")}

+ + {/* show error only if usernames should be shown to be unknown */} + {(!sessionFactors || !loginName) && + !loginSettings?.ignoreUnknownUsernames && ( +
+ {tError("unknownContext")} +
+ )} + + {sessionFactors && ( + + )} + + {passwordComplexity && + loginName && + sessionFactors?.factors?.user?.id ? ( + + ) : ( +
+ {tError("failedLoading")} +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/password/page.tsx b/login/apps/login/src/app/(login)/password/page.tsx new file mode 100644 index 0000000000..506454a275 --- /dev/null +++ b/login/apps/login/src/app/(login)/password/page.tsx @@ -0,0 +1,105 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { PasswordForm } from "@/components/password-form"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getDefaultOrg, + getLoginSettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "password" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + let { loginName, organization, requestId, alt } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + + if (org) { + defaultOrganization = org.id; + } + } + + // also allow no session to be found (ignoreUnkownUsername) + let sessionFactors; + try { + sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + } catch (error) { + // ignore error to continue to show the password form + console.warn(error); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ {sessionFactors?.factors?.user?.displayName ?? t("verify.title")} +

+

{t("verify.description")}

+ + {/* show error only if usernames should be shown to be unknown */} + {(!sessionFactors || !loginName) && + !loginSettings?.ignoreUnknownUsernames && ( +
+ {tError("unknownContext")} +
+ )} + + {sessionFactors && ( + + )} + + {loginName && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/password/set/page.tsx b/login/apps/login/src/app/(login)/password/set/page.tsx new file mode 100644 index 0000000000..26e065438c --- /dev/null +++ b/login/apps/login/src/app/(login)/password/set/page.tsx @@ -0,0 +1,124 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SetPasswordForm } from "@/components/set-password-form"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getPasswordComplexitySettings, + getUserByID, +} from "@/lib/zitadel"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "password" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { userId, loginName, organization, requestId, code, initial } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // also allow no session to be found (ignoreUnkownUsername) + let session: Session | undefined; + if (loginName) { + session = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const passwordComplexity = await getPasswordComplexitySettings({ + serviceUrl, + organization: session?.factors?.user?.organizationId, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + let user: User | undefined; + let displayName: string | undefined; + if (userId) { + const userResponse = await getUserByID({ + serviceUrl, + userId, + }); + user = userResponse.user; + + if (user?.type.case === "human") { + displayName = (user.type.value as HumanUser).profile?.displayName; + } + } + + return ( + +
+

{session?.factors?.user?.displayName ?? t("set.title")}

+

{t("set.description")}

+ + {/* show error only if usernames should be shown to be unknown */} + {loginName && !session && !loginSettings?.ignoreUnknownUsernames && ( +
+ {tError("unknownContext")} +
+ )} + + {session ? ( + + ) : user ? ( + + ) : null} + + {!initial && {t("set.codeSent")}} + + {passwordComplexity && + (loginName ?? user?.preferredLoginName) && + (userId ?? session?.factors?.user?.id) ? ( + + ) : ( +
+ {tError("failedLoading")} +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/register/page.tsx b/login/apps/login/src/app/(login)/register/page.tsx new file mode 100644 index 0000000000..be872b4dc7 --- /dev/null +++ b/login/apps/login/src/app/(login)/register/page.tsx @@ -0,0 +1,123 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterForm } from "@/components/register-form"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getDefaultOrg, + getLegalAndSupportSettings, + getLoginSettings, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "register" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + let { firstname, lastname, email, organization, requestId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + + const legal = await getLegalAndSupportSettings({ + serviceUrl, + organization, + }); + const passwordComplexitySettings = await getPasswordComplexitySettings({ + serviceUrl, + organization, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization, + }).then((resp) => { + return resp.identityProviders.filter((idp) => { + return idp.options?.isAutoCreation || idp.options?.isCreationAllowed; // check if IDP allows to create account automatically or manual creation is allowed + }); + }); + + if (!loginSettings?.allowRegister) { + return ( + +
+

{t("disabled.title")}

+

{t("disabled.description")}

+
+
+ ); + } + + return ( + +
+

{t("title")}

+

{t("description")}

+ + {!organization && {tError("unknownContext")}} + + {legal && + passwordComplexitySettings && + organization && + (loginSettings.allowUsernamePassword || + loginSettings.passkeysType == PasskeysType.ALLOWED) && ( + + )} + + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( + <> +
+

{t("orUseIDP")}

+
+ + + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/register/password/page.tsx b/login/apps/login/src/app/(login)/register/password/page.tsx new file mode 100644 index 0000000000..d6a24fa47f --- /dev/null +++ b/login/apps/login/src/app/(login)/register/password/page.tsx @@ -0,0 +1,90 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SetRegisterPasswordForm } from "@/components/set-register-password-form"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getBrandingSettings, + getDefaultOrg, + getLegalAndSupportSettings, + getLoginSettings, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "register" }); + + let { firstname, lastname, email, organization, requestId } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + + const missingData = !firstname || !lastname || !email || !organization; + + const legal = await getLegalAndSupportSettings({ + serviceUrl, + organization, + }); + const passwordComplexitySettings = await getPasswordComplexitySettings({ + serviceUrl, + organization, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + return missingData ? ( + +
+

{t("missingdata.title")}

+

{t("missingdata.description")}

+
+
+ ) : loginSettings?.allowRegister && loginSettings.allowUsernamePassword ? ( + +
+

{t("password.title")}

+

{t("description")}

+ + {legal && passwordComplexitySettings && ( + + )} +
+
+ ) : ( + +
+

{t("disabled.title")}

+

{t("disabled.description")}

+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/saml-post/page.tsx b/login/apps/login/src/app/(login)/saml-post/page.tsx new file mode 100644 index 0000000000..d5765c8d56 --- /dev/null +++ b/login/apps/login/src/app/(login)/saml-post/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +export default function SamlPost() { + const searchParams = useSearchParams(); + + const url = searchParams.get("url"); + const relayState = searchParams.get("RelayState"); + const samlResponse = searchParams.get("SAMLResponse"); + + useEffect(() => { + // Automatically submit the form after rendering + const form = document.getElementById("samlForm") as HTMLFormElement; + if (form) { + form.submit(); + } + }, []); + + if (!url || !relayState || !samlResponse) { + return ( +

Missing required parameters for SAML POST.

+ ); + } + + return ( + + + + + Redirecting... + + +
+ + +
+

Redirecting...

+ + + ); +} diff --git a/login/apps/login/src/app/(login)/signedin/page.tsx b/login/apps/login/src/app/(login)/signedin/page.tsx new file mode 100644 index 0000000000..dd19096244 --- /dev/null +++ b/login/apps/login/src/app/(login)/signedin/page.tsx @@ -0,0 +1,133 @@ +import { Alert, AlertType } from "@/components/alert"; +import { Button, ButtonVariants } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { + getMostRecentCookieWithLoginname, + getSessionCookieById, +} from "@/lib/cookies"; +import { completeDeviceAuthorization } from "@/lib/server/device"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, +} from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; + +async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, +) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); +} + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "signedin" }); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, sessionId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + // complete device authorization flow if device requestId is present + if (requestId && requestId.startsWith("device_")) { + const cookie = sessionId + ? await getSessionCookieById({ sessionId, organization }) + : await getMostRecentCookieWithLoginname({ + loginName: loginName, + organization: organization, + }); + + await completeDeviceAuthorization(requestId.replace("device_", ""), { + sessionId: cookie.id, + sessionToken: cookie.token, + }).catch((err) => { + return ( + +
+

{t("error.title")}

+

{t("error.description")}

+ {err.message} +
+
+ ); + }); + } + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + let loginSettings; + if (!requestId) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + } + + return ( + +
+

+ {t("title", { user: sessionFactors?.factors?.user?.displayName })} +

+

{t("description")}

+ + + + {requestId && requestId.startsWith("device_") && ( + + You can now close this window and return to the device where you + started the authorization process to continue. + + )} + + {loginSettings?.defaultRedirectUri && ( +
+ + + + + +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/u2f/page.tsx b/login/apps/login/src/app/(login)/u2f/page.tsx new file mode 100644 index 0000000000..b16dc88f4b --- /dev/null +++ b/login/apps/login/src/app/(login)/u2f/page.tsx @@ -0,0 +1,89 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LoginPasskey } from "@/components/login-passkey"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getSession } from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "u2f" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, requestId, sessionId, organization } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + + async function loadSessionById( + host: string, + sessionId: string, + organization?: string, + ) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + return ( + +
+

{t("verify.title")}

+ + {sessionFactors && ( + + )} +

{t("verify.description")}

+ + {!(loginName || sessionId) && {tError("unknownContext")}} + + {(loginName || sessionId) && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/u2f/set/page.tsx b/login/apps/login/src/app/(login)/u2f/set/page.tsx new file mode 100644 index 0000000000..b31b87c202 --- /dev/null +++ b/login/apps/login/src/app/(login)/u2f/set/page.tsx @@ -0,0 +1,70 @@ +import { Alert } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterU2f } from "@/components/register-u2f"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings } from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "u2f" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { loginName, organization, requestId, checkAfter } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

{t("set.title")}

+ + {sessionFactors && ( + + )} +

{t("set.description")}

+ + {!sessionFactors && ( +
+ {tError("unknownContext")} +
+ )} + + {sessionFactors?.id && ( + + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/verify/page.tsx b/login/apps/login/src/app/(login)/verify/page.tsx new file mode 100644 index 0000000000..7634ff063a --- /dev/null +++ b/login/apps/login/src/app/(login)/verify/page.tsx @@ -0,0 +1,172 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { VerifyForm } from "@/components/verify-form"; +import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "verify" }); + const tError = await getTranslations({ locale, namespace: "error" }); + + const { userId, loginName, code, organization, requestId, invite, send } = + searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + let sessionFactors; + let user: User | undefined; + let human: HumanUser | undefined; + let id: string | undefined; + + const doSend = send === "true"; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + async function sendEmail(userId: string) { + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + if (invite === "true") { + await sendInviteEmailCode({ + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (requestId ? `&requestId=${requestId}` : ""), + }).catch((error) => { + console.error("Could not send invitation email", error); + throw Error("Failed to send invitation email"); + }); + } else { + await sendEmailCode({ + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (requestId ? `&requestId=${requestId}` : ""), + }).catch((error) => { + console.error("Could not send verification email", error); + throw Error("Failed to send verification email"); + }); + } + } + + if ("loginName" in searchParams) { + sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + if (doSend && sessionFactors?.factors?.user?.id) { + await sendEmail(sessionFactors.factors.user.id); + } + } else if ("userId" in searchParams && userId) { + if (doSend) { + await sendEmail(userId); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId, + }); + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + } + } + + id = userId ?? sessionFactors?.factors?.user?.id; + + if (!id) { + throw Error("Failed to get user id"); + } + + const params = new URLSearchParams({ + userId: userId, + initial: "true", // defines that a code is not required and is therefore not shown in the UI + }); + + if (loginName) { + params.set("loginName", loginName); + } + + if (organization) { + params.set("organization", organization); + } + + if (requestId) { + params.set("requestId", requestId); + } + + return ( + +
+

{t("verify.title")}

+

{t("verify.description")}

+ + {!id && ( + <> +

{t("verify.title")}

+

{t("verify.description")}

+ +
+ {tError("unknownContext")} +
+ + )} + + {id && send && ( +
+ {t("verify.codeSent")} +
+ )} + + {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} + + +
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/verify/success/page.tsx b/login/apps/login/src/app/(login)/verify/success/page.tsx new file mode 100644 index 0000000000..1668e2e3fd --- /dev/null +++ b/login/apps/login/src/app/(login)/verify/success/page.tsx @@ -0,0 +1,90 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getUserByID, +} from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "verify" }); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, userId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }).catch((error) => { + console.warn("Error loading session:", error); + }); + + let loginSettings; + if (!requestId) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + } + + const id = userId ?? sessionFactors?.factors?.user?.id; + + if (!id) { + throw Error("Failed to get user id"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: id, + }); + + let user: User | undefined; + let human: HumanUser | undefined; + + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + } + + return ( + +
+

{t("successTitle")}

+

{t("successDescription")}

+ + {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} +
+
+ ); +} diff --git a/login/apps/login/src/app/global-error.tsx b/login/apps/login/src/app/global-error.tsx new file mode 100644 index 0000000000..0fea5d0117 --- /dev/null +++ b/login/apps/login/src/app/global-error.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Boundary } from "@/components/boundary"; +import { Button } from "@/components/button"; +import { ThemeWrapper } from "@/components/theme-wrapper"; +import { useTranslations } from "next-intl"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations("error"); + + return ( + // global-error must include html and body tags + + + + +
+
+ Error: {error?.message} +
+
+ +
+
+
+
+ + + ); +} diff --git a/login/apps/login/src/app/login/route.ts b/login/apps/login/src/app/login/route.ts new file mode 100644 index 0000000000..0e181b76f1 --- /dev/null +++ b/login/apps/login/src/app/login/route.ts @@ -0,0 +1,536 @@ +import { getAllSessions } from "@/lib/cookies"; +import { idpTypeToSlug } from "@/lib/idp"; +import { loginWithOIDCAndSession } from "@/lib/oidc"; +import { loginWithSAMLAndSession } from "@/lib/saml"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { constructUrl, getServiceUrlFromHeaders } from "@/lib/service-url"; +import { findValidSession } from "@/lib/session"; +import { + createCallback, + createResponse, + getActiveIdentityProviders, + getAuthRequest, + getOrgsByDomain, + getSAMLRequest, + getSecuritySettings, + listSessions, + startIdentityProviderFlow, +} from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { + CreateCallbackRequestSchema, + SessionSchema, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../../../constants/csp"; + +export const dynamic = "force-dynamic"; +export const revalidate = false; +export const fetchCache = "default-no-store"; + +const gotoAccounts = ({ + request, + requestId, + organization, +}: { + request: NextRequest; + requestId: string; + organization?: string; +}): NextResponse => { + const accountsUrl = constructUrl(request, "/accounts"); + + if (requestId) { + accountsUrl.searchParams.set("requestId", requestId); + } + if (organization) { + accountsUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(accountsUrl); +}; + +async function loadSessions({ + serviceUrl, + ids, +}: { + serviceUrl: string; + ids: string[]; +}): Promise { + const response = await listSessions({ + serviceUrl, + ids: ids.filter((id: string | undefined) => !!id), + }); + + return response?.sessions ?? []; +} + +const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/; +const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options +const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/; + +export async function GET(request: NextRequest) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const searchParams = request.nextUrl.searchParams; + + const oidcRequestId = searchParams.get("authRequest"); // oidc initiated request + const samlRequestId = searchParams.get("samlRequest"); // saml initiated request + + // internal request id which combines authRequest and samlRequest with the prefix oidc_ or saml_ + let requestId = + searchParams.get("requestId") ?? + (oidcRequestId + ? `oidc_${oidcRequestId}` + : samlRequestId + ? `saml_${samlRequestId}` + : undefined); + + const sessionId = searchParams.get("sessionId"); + + // TODO: find a better way to handle _rsc (react server components) requests and block them to avoid conflicts when creating oidc callback + const _rsc = searchParams.get("_rsc"); + if (_rsc) { + return NextResponse.json({ error: "No _rsc supported" }, { status: 500 }); + } + + const sessionCookies = await getAllSessions(); + const ids = sessionCookies.map((s) => s.id); + let sessions: Session[] = []; + if (ids && ids.length) { + sessions = await loadSessions({ serviceUrl, ids }); + } + + // complete flow if session and request id are provided + if (requestId && sessionId) { + if (requestId.startsWith("oidc_")) { + // this finishes the login process for OIDC + return loginWithOIDCAndSession({ + serviceUrl, + authRequest: requestId.replace("oidc_", ""), + sessionId, + sessions, + sessionCookies, + request, + }); + } else if (requestId.startsWith("saml_")) { + // this finishes the login process for SAML + return loginWithSAMLAndSession({ + serviceUrl, + samlRequest: requestId.replace("saml_", ""), + sessionId, + sessions, + sessionCookies, + request, + }); + } + } + + // continue with OIDC + if (requestId && requestId.startsWith("oidc_")) { + const { authRequest } = await getAuthRequest({ + serviceUrl, + authRequestId: requestId.replace("oidc_", ""), + }); + + let organization = ""; + let suffix = ""; + let idpId = ""; + + if (authRequest?.scope) { + const orgScope = authRequest.scope.find((s: string) => + ORG_SCOPE_REGEX.test(s), + ); + + const idpScope = authRequest.scope.find((s: string) => + IDP_SCOPE_REGEX.test(s), + ); + + if (orgScope) { + const matched = ORG_SCOPE_REGEX.exec(orgScope); + organization = matched?.[1] ?? ""; + } else { + const orgDomainScope = authRequest.scope.find((s: string) => + ORG_DOMAIN_SCOPE_REGEX.test(s), + ); + + if (orgDomainScope) { + const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope); + const orgDomain = matched?.[1] ?? ""; + if (orgDomain) { + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: orgDomain, + }); + if (orgs.result && orgs.result.length === 1) { + organization = orgs.result[0].id ?? ""; + suffix = orgDomain; + } + } + } + } + + if (idpScope) { + const matched = IDP_SCOPE_REGEX.exec(idpScope); + idpId = matched?.[1] ?? ""; + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization ? organization : undefined, + }).then((resp) => { + return resp.identityProviders; + }); + + const idp = identityProviders.find((idp) => idp.id === idpId); + + if (idp) { + const origin = request.nextUrl.origin; + + const identityProviderType = identityProviders[0].type; + let provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams(); + + if (requestId) { + params.set("requestId", requestId); + } + + if (organization) { + params.set("organization", organization); + } + + return startIdentityProviderFlow({ + serviceUrl, + idpId, + urls: { + successUrl: + `${origin}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${origin}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }).then((resp) => { + if ( + resp.nextStep.value && + typeof resp.nextStep.value === "string" + ) { + return NextResponse.redirect(resp.nextStep.value); + } + }); + } + } + } + + if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) { + const registerUrl = constructUrl(request, "/register"); + if (authRequest.id) { + registerUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (organization) { + registerUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(registerUrl); + } + + // use existing session and hydrate it for oidc + if (authRequest && sessions.length) { + // if some accounts are available for selection and select_account is set + if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } else if (authRequest.prompt.includes(Prompt.LOGIN)) { + /** + * The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated + */ + + // if a hint is provided, skip loginname page and jump to the next page + if (authRequest.loginHint) { + try { + let command: SendLoginnameCommand = { + loginName: authRequest.loginHint, + requestId: authRequest.id, + }; + + if (organization) { + command = { ...command, organization }; + } + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } catch (error) { + console.error("Failed to execute sendLoginname:", error); + } + } + + const loginNameUrl = constructUrl(request, "/loginname"); + if (authRequest.id) { + loginNameUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (authRequest.loginHint) { + loginNameUrl.searchParams.set("loginName", authRequest.loginHint); + } + if (organization) { + loginNameUrl.searchParams.set("organization", organization); + } + if (suffix) { + loginNameUrl.searchParams.set("suffix", suffix); + } + return NextResponse.redirect(loginNameUrl); + } else if (authRequest.prompt.includes(Prompt.NONE)) { + /** + * With an OIDC none prompt, the authentication server must not display any authentication or consent user interface pages. + * This means that the user should not be prompted to enter their password again. + * Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction + **/ + const securitySettings = await getSecuritySettings({ + serviceUrl, + }); + + const selectedSession = await findValidSession({ + serviceUrl, + sessions, + authRequest, + }); + + const noSessionResponse = NextResponse.json( + { error: "No active session found" }, + { status: 400 }, + ); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + noSessionResponse.headers.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + noSessionResponse.headers.delete("X-Frame-Options"); + } + + if (!selectedSession || !selectedSession.id) { + return noSessionResponse; + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession.id, + ); + + if (!cookie || !cookie.id || !cookie.token) { + return noSessionResponse; + } + + const session = { + sessionId: cookie.id, + sessionToken: cookie.token, + }; + + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: requestId.replace("oidc_", ""), + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + + const callbackResponse = NextResponse.redirect(callbackUrl); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + callbackResponse.headers.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + callbackResponse.headers.delete("X-Frame-Options"); + } + + return callbackResponse; + } else { + // check for loginHint, userId hint and valid sessions + let selectedSession = await findValidSession({ + serviceUrl, + sessions, + authRequest, + }); + + if (!selectedSession || !selectedSession.id) { + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession.id, + ); + + if (!cookie || !cookie.id || !cookie.token) { + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } + + const session = { + sessionId: cookie.id, + sessionToken: cookie.token, + }; + + try { + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: requestId.replace("oidc_", ""), + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + if (callbackUrl) { + return NextResponse.redirect(callbackUrl); + } else { + console.log( + "could not create callback, redirect user to choose other account", + ); + return gotoAccounts({ + request, + organization, + requestId: `oidc_${authRequest.id}`, + }); + } + } catch (error) { + console.error(error); + return gotoAccounts({ + request, + requestId: `oidc_${authRequest.id}`, + organization, + }); + } + } + } else { + const loginNameUrl = constructUrl(request, "/loginname"); + + loginNameUrl.searchParams.set("requestId", requestId); + if (authRequest?.loginHint) { + loginNameUrl.searchParams.set("loginName", authRequest.loginHint); + loginNameUrl.searchParams.set("submit", "true"); // autosubmit + } + + if (organization) { + loginNameUrl.searchParams.append("organization", organization); + // loginNameUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(loginNameUrl); + } + } + // continue with SAML + else if (requestId && requestId.startsWith("saml_")) { + const { samlRequest } = await getSAMLRequest({ + serviceUrl, + samlRequestId: requestId.replace("saml_", ""), + }); + + if (!samlRequest) { + return NextResponse.json( + { error: "No samlRequest found" }, + { status: 400 }, + ); + } + + let selectedSession = await findValidSession({ + serviceUrl, + sessions, + samlRequest, + }); + + if (!selectedSession || !selectedSession.id) { + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + }); + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession.id, + ); + + if (!cookie || !cookie.id || !cookie.token) { + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + // organization, + }); + } + + const session = { + sessionId: cookie.id, + sessionToken: cookie.token, + }; + + try { + const { url, binding } = await createResponse({ + serviceUrl, + req: create(CreateResponseRequestSchema, { + samlRequestId: requestId.replace("saml_", ""), + responseKind: { + case: "session", + value: session, + }, + }), + }); + if (url && binding.case === "redirect") { + return NextResponse.redirect(url); + } else if (url && binding.case === "post") { + const redirectUrl = constructUrl(request, "/saml-post"); + + redirectUrl.searchParams.set("url", url); + redirectUrl.searchParams.set("RelayState", binding.value.relayState); + redirectUrl.searchParams.set( + "SAMLResponse", + binding.value.samlResponse, + ); + + return NextResponse.redirect(redirectUrl.toString()); + } else { + console.log( + "could not create response, redirect user to choose other account", + ); + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + }); + } + } catch (error) { + console.error(error); + return gotoAccounts({ + request, + requestId: `saml_${samlRequest.id}`, + }); + } + } + // Device Authorization does not need to start here as it is handled on the /device endpoint + else { + return NextResponse.json( + { error: "No authRequest nor samlRequest provided" }, + { status: 500 }, + ); + } +} diff --git a/login/apps/login/src/app/security/route.ts b/login/apps/login/src/app/security/route.ts new file mode 100644 index 0000000000..4a2b6d4854 --- /dev/null +++ b/login/apps/login/src/app/security/route.ts @@ -0,0 +1,28 @@ +import { createServiceForHost } from "@/lib/service"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { Client } from "@zitadel/client"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const settings = await settingsService + .getSecuritySettings({}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + const response = NextResponse.json({ settings }, { status: 200 }); + + // Add Cache-Control header to cache the response for up to 1 hour + response.headers.set( + "Cache-Control", + "public, max-age=3600, stale-while-revalidate=86400", + ); + + return response; +} diff --git a/login/apps/login/src/components/address-bar.tsx b/login/apps/login/src/components/address-bar.tsx new file mode 100644 index 0000000000..7e7bda6bd0 --- /dev/null +++ b/login/apps/login/src/components/address-bar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { Fragment } from "react"; + +type Props = { + domain: string; +}; + +export function AddressBar({ domain }: Props) { + const pathname = usePathname(); + + return ( +
+
+ + + +
+
+
+ {domain} +
+ {pathname ? ( + <> + / + {pathname + .split("/") + .slice(1) + .filter((s) => !!s) + .map((segment) => { + return ( + + + + {segment} + + + + / + + ); + })} + + ) : null} +
+
+ ); +} diff --git a/login/apps/login/src/components/alert.tsx b/login/apps/login/src/components/alert.tsx new file mode 100644 index 0000000000..417e67934e --- /dev/null +++ b/login/apps/login/src/components/alert.tsx @@ -0,0 +1,45 @@ +import { + ExclamationTriangleIcon, + InformationCircleIcon, +} from "@heroicons/react/24/outline"; +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; + type?: AlertType; +}; + +export enum AlertType { + ALERT, + INFO, +} + +const yellow = + "border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200"; +const red = + "border-red-600/40 dark:border-red-500/20 bg-red-200/30 text-red-600 dark:bg-red-700/20 dark:text-red-200"; +const neutral = + "border-divider-light dark:border-divider-dark bg-black/5 text-gray-600 dark:bg-white/10 dark:text-gray-200"; + +export function Alert({ children, type = AlertType.ALERT }: Props) { + return ( +
+ {type === AlertType.ALERT && ( + + )} + {type === AlertType.INFO && ( + + )} + {children} +
+ ); +} diff --git a/login/apps/login/src/components/app-avatar.tsx b/login/apps/login/src/components/app-avatar.tsx new file mode 100644 index 0000000000..defe388438 --- /dev/null +++ b/login/apps/login/src/components/app-avatar.tsx @@ -0,0 +1,48 @@ +import { ColorShade, getColorHash } from "@/helpers/colors"; +import { useTheme } from "next-themes"; +import Image from "next/image"; +import { getInitials } from "./avatar"; + +interface AvatarProps { + appName: string; + imageUrl?: string; + shadow?: boolean; +} + +export function AppAvatar({ appName, imageUrl, shadow }: AvatarProps) { + const { resolvedTheme } = useTheme(); + const credentials = getInitials(appName, appName); + + const color: ColorShade = getColorHash(appName); + + const avatarStyleDark = { + backgroundColor: color[900], + color: color[200], + }; + + const avatarStyleLight = { + backgroundColor: color[200], + color: color[900], + }; + + return ( +
+ {imageUrl ? ( + avatar + ) : ( + {credentials} + )} +
+ ); +} diff --git a/login/apps/login/src/components/auth-methods.tsx b/login/apps/login/src/components/auth-methods.tsx new file mode 100644 index 0000000000..ff0bcf0b32 --- /dev/null +++ b/login/apps/login/src/components/auth-methods.tsx @@ -0,0 +1,234 @@ +import { CheckIcon } from "@heroicons/react/24/solid"; +import { clsx } from "clsx"; +import Link from "next/link"; +import { ReactNode } from "react"; +import { BadgeState, StateBadge } from "./state-badge"; + +const cardClasses = (alreadyAdded: boolean) => + clsx( + "relative bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 border border-divider-light dark:border-divider-dark transition-all ", + alreadyAdded + ? "opacity-50 cursor-default" + : "hover:shadow-lg hover:dark:bg-white/10", + ); + +const LinkWrapper = ({ + alreadyAdded, + children, + link, +}: { + alreadyAdded: boolean; + children: ReactNode; + link: string; +}) => { + return !alreadyAdded ? ( + + {children} + + ) : ( +
{children}
+ ); +}; + +export const TOTP = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + timer-lock-outline + + {" "} + Authenticator App +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const U2F = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + Universal Second Factor +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const EMAIL = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + + Code via Email +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const SMS = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + Code via SMS +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const PASSKEYS = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + + + Passkeys +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +export const PASSWORD = (alreadyAdded: boolean, link: string) => { + return ( + +
+ + form-textbox-password + + + Password +
+ {alreadyAdded && ( + <> + + + )} +
+ ); +}; + +function Setup() { + return ( +
+ + + +
+ ); +} diff --git a/login/apps/login/src/components/authentication-method-radio.tsx b/login/apps/login/src/components/authentication-method-radio.tsx new file mode 100644 index 0000000000..1b2af2d167 --- /dev/null +++ b/login/apps/login/src/components/authentication-method-radio.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { RadioGroup } from "@headlessui/react"; +import { useTranslations } from "next-intl"; + +export enum AuthenticationMethod { + Passkey = "passkey", + Password = "password", +} + +export const methods = [ + AuthenticationMethod.Passkey, + AuthenticationMethod.Password, +]; + +export function AuthenticationMethodRadio({ + selected, + selectionChanged, +}: { + selected: any; + selectionChanged: (value: any) => void; +}) { + const t = useTranslations("register"); + + return ( +
+
+ + Server size +
+ {methods.map((method) => ( + + `${ + active + ? "ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20" + : "" + } + ${ + checked + ? "bg-background-light-400 dark:bg-background-dark-400 ring-2 ring-primary-light-500 dark:ring-primary-dark-500" + : "bg-background-light-400 dark:bg-background-dark-400" + } + h-full flex-1 relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10` + } + > + {({ active, checked }) => ( + <> +
+ {method === "passkey" && ( + + + + )} + {method === "password" && ( + + form-textbox-password + + + )} + + {t(`methods.${method}`)} + +
+ + )} +
+ ))} +
+
+
+
+ ); +} diff --git a/login/apps/login/src/components/avatar.tsx b/login/apps/login/src/components/avatar.tsx new file mode 100644 index 0000000000..2300659875 --- /dev/null +++ b/login/apps/login/src/components/avatar.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { ColorShade, getColorHash } from "@/helpers/colors"; +import { useTheme } from "next-themes"; +import Image from "next/image"; + +interface AvatarProps { + name: string | null | undefined; + loginName: string; + imageUrl?: string; + size?: "small" | "base" | "large"; + shadow?: boolean; +} + +export function getInitials(name: string, loginName: string) { + let credentials = ""; + if (name) { + const split = name.split(" "); + if (split) { + const initials = + split[0].charAt(0) + (split[1] ? split[1].charAt(0) : ""); + credentials = initials; + } else { + credentials = name.charAt(0); + } + } else { + const username = loginName.split("@")[0]; + let separator = "_"; + if (username.includes("-")) { + separator = "-"; + } + if (username.includes(".")) { + separator = "."; + } + const split = username.split(separator); + const initials = split[0].charAt(0) + (split[1] ? split[1].charAt(0) : ""); + credentials = initials; + } + + return credentials; +} + +export function Avatar({ + size = "base", + name, + loginName, + imageUrl, + shadow, +}: AvatarProps) { + const { resolvedTheme } = useTheme(); + const credentials = getInitials(name ?? loginName, loginName); + + const color: ColorShade = getColorHash(loginName); + + const avatarStyleDark = { + backgroundColor: color[900], + color: color[200], + }; + + const avatarStyleLight = { + backgroundColor: color[200], + color: color[900], + }; + + return ( +
+ {imageUrl ? ( + avatar + ) : ( + + {credentials} + + )} +
+ ); +} diff --git a/login/apps/login/src/components/back-button.tsx b/login/apps/login/src/components/back-button.tsx new file mode 100644 index 0000000000..fe348af9c4 --- /dev/null +++ b/login/apps/login/src/components/back-button.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { Button, ButtonVariants } from "./button"; + +export function BackButton() { + const t = useTranslations("common"); + const router = useRouter(); + return ( + + ); +} diff --git a/login/apps/login/src/components/boundary.tsx b/login/apps/login/src/components/boundary.tsx new file mode 100644 index 0000000000..354d920960 --- /dev/null +++ b/login/apps/login/src/components/boundary.tsx @@ -0,0 +1,83 @@ +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +const Label = ({ + children, + animateRerendering, + color, +}: { + children: ReactNode; + animateRerendering?: boolean; + color?: "default" | "pink" | "blue" | "violet" | "cyan" | "orange" | "red"; +}) => { + return ( +
+ {children} +
+ ); +}; +export const Boundary = ({ + children, + labels = ["children"], + size = "default", + color = "default", + animateRerendering = true, +}: { + children: ReactNode; + labels?: string[]; + size?: "small" | "default"; + color?: "default" | "pink" | "blue" | "violet" | "cyan" | "orange" | "red"; + animateRerendering?: boolean; +}) => { + return ( +
+
+ {labels.map((label) => { + return ( + + ); + })} +
+ + {children} +
+ ); +}; diff --git a/login/apps/login/src/components/button.tsx b/login/apps/login/src/components/button.tsx new file mode 100644 index 0000000000..a25a30538a --- /dev/null +++ b/login/apps/login/src/components/button.tsx @@ -0,0 +1,74 @@ +import { clsx } from "clsx"; +import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react"; + +export enum ButtonSizes { + Small = "Small", + Large = "Large", +} + +export enum ButtonVariants { + Primary = "Primary", + Secondary = "Secondary", + Destructive = "Destructive", +} + +export enum ButtonColors { + Neutral = "Neutral", + Primary = "Primary", + Warn = "Warn", +} + +export type ButtonProps = DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement +> & { + size?: ButtonSizes; + variant?: ButtonVariants; + color?: ButtonColors; +}; + +export const getButtonClasses = ( + size: ButtonSizes, + variant: ButtonVariants, + color: ButtonColors, +) => + clsx({ + "box-border font-normal leading-36px text-14px inline-flex items-center rounded-md focus:outline-none transition-colors transition-shadow duration-300": + true, + "shadow hover:shadow-xl active:shadow-xl disabled:border-none disabled:bg-gray-300 disabled:text-gray-600 disabled:shadow-none disabled:cursor-not-allowed disabled:dark:bg-gray-800 disabled:dark:text-gray-900": + variant === ButtonVariants.Primary, + "bg-primary-light-500 dark:bg-primary-dark-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-400 text-primary-light-contrast-500 dark:text-primary-dark-contrast-500": + variant === ButtonVariants.Primary && color !== ButtonColors.Warn, + "bg-warn-light-500 dark:bg-warn-dark-500 hover:bg-warn-light-400 hover:dark:bg-warn-dark-400 text-white dark:text-white": + variant === ButtonVariants.Primary && color === ButtonColors.Warn, + "border border-button-light-border dark:border-button-dark-border text-gray-950 hover:bg-gray-500 hover:bg-opacity-20 hover:dark:bg-white hover:dark:bg-opacity-10 focus:bg-gray-500 focus:bg-opacity-20 focus:dark:bg-white focus:dark:bg-opacity-10 dark:text-white disabled:text-gray-600 disabled:hover:bg-transparent disabled:dark:hover:bg-transparent disabled:cursor-not-allowed disabled:dark:text-gray-900": + variant === ButtonVariants.Secondary, + "border border-button-light-border dark:border-button-dark-border text-warn-light-500 dark:text-warn-dark-500 hover:bg-warn-light-500 hover:bg-opacity-10 dark:hover:bg-warn-light-500 dark:hover:bg-opacity-10 focus:bg-warn-light-500 focus:bg-opacity-20 dark:focus:bg-warn-light-500 dark:focus:bg-opacity-20": + color === ButtonColors.Warn && variant !== ButtonVariants.Primary, + "px-16 py-2": size === ButtonSizes.Large, + "px-4 h-[36px]": size === ButtonSizes.Small, + }); + +// eslint-disable-next-line react/display-name +export const Button = forwardRef( + ( + { + children, + className = "", + variant = ButtonVariants.Primary, + size = ButtonSizes.Small, + color = ButtonColors.Primary, + ...props + }, + ref, + ) => ( + + ), +); diff --git a/login/apps/login/src/components/change-password-form.tsx b/login/apps/login/src/components/change-password-form.tsx new file mode 100644 index 0000000000..54aab7b3ca --- /dev/null +++ b/login/apps/login/src/components/change-password-form.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { + checkSessionAndSetPassword, + sendPassword, +} from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PasswordComplexity } from "./password-complexity"; +import { Spinner } from "./spinner"; + +type Inputs = + | { + password: string; + confirmPassword: string; + } + | FieldValues; + +type Props = { + passwordComplexitySettings: PasswordComplexitySettings; + sessionId: string; + loginName: string; + requestId?: string; + organization?: string; +}; + +export function ChangePasswordForm({ + passwordComplexitySettings, + sessionId, + loginName, + requestId, + organization, +}: Props) { + const t = useTranslations("password"); + const router = useRouter(); + + const { register, handleSubmit, watch, formState } = useForm({ + mode: "onBlur", + defaultValues: { + password: "", + comfirmPassword: "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitChange(values: Inputs) { + setLoading(true); + + const changeResponse = checkSessionAndSetPassword({ + sessionId, + password: values.password, + }) + .catch(() => { + setError("Could not change password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (changeResponse && "error" in changeResponse && changeResponse.error) { + setError( + typeof changeResponse.error === "string" + ? changeResponse.error + : "Unknown error", + ); + return; + } + + if (!changeResponse) { + setError("Could not change password"); + return; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for a second, to prevent eventual consistency issues + + const passwordResponse = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if ( + passwordResponse && + "error" in passwordResponse && + passwordResponse.error + ) { + setError(passwordResponse.error); + return; + } + + if ( + passwordResponse && + "redirect" in passwordResponse && + passwordResponse.redirect + ) { + return router.push(passwordResponse.redirect); + } + + return; + } + + const { errors } = formState; + + const watchPassword = watch("password", ""); + const watchConfirmPassword = watch("confirmPassword", ""); + + const hasMinLength = + passwordComplexitySettings && + watchPassword?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(watchPassword); + const hasNumber = numberValidator(watchPassword); + const hasUppercase = upperCaseValidator(watchPassword); + const hasLowercase = lowerCaseValidator(watchPassword); + + const policyIsValid = + passwordComplexitySettings && + (passwordComplexitySettings.requiresLowercase ? hasLowercase : true) && + (passwordComplexitySettings.requiresNumber ? hasNumber : true) && + (passwordComplexitySettings.requiresUppercase ? hasUppercase : true) && + (passwordComplexitySettings.requiresSymbol ? hasSymbol : true) && + hasMinLength; + + return ( +
+
+
+ +
+
+ +
+
+ + {passwordComplexitySettings && ( + + )} + + {error && {error}} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/checkbox.tsx b/login/apps/login/src/components/checkbox.tsx new file mode 100644 index 0000000000..41b45aad92 --- /dev/null +++ b/login/apps/login/src/components/checkbox.tsx @@ -0,0 +1,62 @@ +import classNames from "clsx"; +import { + DetailedHTMLProps, + forwardRef, + InputHTMLAttributes, + useEffect, + useState, +} from "react"; + +export type CheckboxProps = DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement +> & { + checked: boolean; + disabled?: boolean; + onChangeVal?: (checked: boolean) => void; +}; + +export const Checkbox = forwardRef( + function Checkbox( + { + className = "", + checked = false, + disabled = false, + onChangeVal, + children, + ...props + }, + ref, + ) { + const [enabled, setEnabled] = useState(checked); + + useEffect(() => { + setEnabled(checked); + }, [checked]); + + return ( +
+
+
+ { + setEnabled(event.target?.checked); + onChangeVal && onChangeVal(event.target?.checked); + }} + disabled={disabled} + type="checkbox" + className={classNames( + "form-checkbox rounded border-gray-300 text-primary-light-500 dark:text-primary-dark-500 shadow-sm focus:border-indigo-300 focus:ring focus:ring-offset-0 focus:ring-indigo-200 focus:ring-opacity-50", + className, + )} + {...props} + /> +
+
+ {children} +
+ ); + }, +); diff --git a/login/apps/login/src/components/choose-authenticator-to-login.tsx b/login/apps/login/src/components/choose-authenticator-to-login.tsx new file mode 100644 index 0000000000..f7e3cdf8f8 --- /dev/null +++ b/login/apps/login/src/components/choose-authenticator-to-login.tsx @@ -0,0 +1,38 @@ +import { + LoginSettings, + PasskeysType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useTranslations } from "next-intl"; +import { PASSKEYS, PASSWORD } from "./auth-methods"; + +type Props = { + authMethods: AuthenticationMethodType[]; + params: URLSearchParams; + loginSettings: LoginSettings | undefined; +}; + +export function ChooseAuthenticatorToLogin({ + authMethods, + params, + loginSettings, +}: Props) { + const t = useTranslations("idp"); + + return ( + <> + {authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings?.allowUsernamePassword && ( +
Choose an alternative method to login
+ )} +
+ {authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings?.allowUsernamePassword && + PASSWORD(false, "/password?" + params)} + {authMethods.includes(AuthenticationMethodType.PASSKEY) && + loginSettings?.passkeysType == PasskeysType.ALLOWED && + PASSKEYS(false, "/passkey?" + params)} +
+ + ); +} diff --git a/login/apps/login/src/components/choose-authenticator-to-setup.tsx b/login/apps/login/src/components/choose-authenticator-to-setup.tsx new file mode 100644 index 0000000000..9075e3286e --- /dev/null +++ b/login/apps/login/src/components/choose-authenticator-to-setup.tsx @@ -0,0 +1,44 @@ +import { + LoginSettings, + PasskeysType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useTranslations } from "next-intl"; +import { Alert, AlertType } from "./alert"; +import { PASSKEYS, PASSWORD } from "./auth-methods"; + +type Props = { + authMethods: AuthenticationMethodType[]; + params: URLSearchParams; + loginSettings: LoginSettings; +}; + +export function ChooseAuthenticatorToSetup({ + authMethods, + params, + loginSettings, +}: Props) { + const t = useTranslations("authenticator"); + + if (authMethods.length !== 0) { + return {t("allSetup")}; + } else { + return ( + <> + {loginSettings.passkeysType == PasskeysType.NOT_ALLOWED && + !loginSettings.allowUsernamePassword && ( + {t("noMethodsAvailable")} + )} + +
+ {!authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings.allowUsernamePassword && + PASSWORD(false, "/password/set?" + params)} + {!authMethods.includes(AuthenticationMethodType.PASSKEY) && + loginSettings.passkeysType == PasskeysType.ALLOWED && + PASSKEYS(false, "/passkey/set?" + params)} +
+ + ); + } +} diff --git a/login/apps/login/src/components/choose-second-factor-to-setup.tsx b/login/apps/login/src/components/choose-second-factor-to-setup.tsx new file mode 100644 index 0000000000..e56379e147 --- /dev/null +++ b/login/apps/login/src/components/choose-second-factor-to-setup.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session"; +import { + LoginSettings, + SecondFactorType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; + +type Props = { + userId: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + loginSettings: LoginSettings; + userMethods: AuthenticationMethodType[]; + checkAfter: boolean; + phoneVerified: boolean; + emailVerified: boolean; + force: boolean; +}; + +export function ChooseSecondFactorToSetup({ + userId, + loginName, + sessionId, + requestId, + organization, + loginSettings, + userMethods, + checkAfter, + phoneVerified, + emailVerified, + force, +}: Props) { + const t = useTranslations("mfa"); + const router = useRouter(); + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (sessionId) { + params.append("sessionId", sessionId); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + if (checkAfter) { + params.append("checkAfter", "true"); + } + + return ( + <> +
+ {loginSettings.secondFactors.map((factor) => { + switch (factor) { + case SecondFactorType.OTP: + return TOTP( + userMethods.includes(AuthenticationMethodType.TOTP), + "/otp/time-based/set?" + params, + ); + case SecondFactorType.U2F: + return U2F( + userMethods.includes(AuthenticationMethodType.U2F), + "/u2f/set?" + params, + ); + case SecondFactorType.OTP_EMAIL: + return ( + emailVerified && + EMAIL( + userMethods.includes(AuthenticationMethodType.OTP_EMAIL), + "/otp/email/set?" + params, + ) + ); + case SecondFactorType.OTP_SMS: + return ( + phoneVerified && + SMS( + userMethods.includes(AuthenticationMethodType.OTP_SMS), + "/otp/sms/set?" + params, + ) + ); + default: + return null; + } + })} +
+ {!force && ( + + )} + + ); +} diff --git a/login/apps/login/src/components/choose-second-factor.tsx b/login/apps/login/src/components/choose-second-factor.tsx new file mode 100644 index 0000000000..6cd890f11d --- /dev/null +++ b/login/apps/login/src/components/choose-second-factor.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; + +type Props = { + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + userMethods: AuthenticationMethodType[]; +}; + +export function ChooseSecondFactor({ + loginName, + sessionId, + requestId, + organization, + userMethods, +}: Props) { + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (sessionId) { + params.append("sessionId", sessionId); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + + return ( +
+ {userMethods.map((method, i) => { + return ( +
+ {method === AuthenticationMethodType.TOTP && + TOTP(false, "/otp/time-based?" + params)} + {method === AuthenticationMethodType.U2F && + U2F(false, "/u2f?" + params)} + {method === AuthenticationMethodType.OTP_EMAIL && + EMAIL(false, "/otp/email?" + params)} + {method === AuthenticationMethodType.OTP_SMS && + SMS(false, "/otp/sms?" + params)} +
+ ); + })} +
+ ); +} diff --git a/login/apps/login/src/components/consent.tsx b/login/apps/login/src/components/consent.tsx new file mode 100644 index 0000000000..d3d30b3113 --- /dev/null +++ b/login/apps/login/src/components/consent.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { completeDeviceAuthorization } from "@/lib/server/device"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Alert } from "./alert"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; + +export function ConsentScreen({ + scope, + nextUrl, + deviceAuthorizationRequestId, + appName, +}: { + scope?: string[]; + nextUrl: string; + deviceAuthorizationRequestId: string; + appName?: string; +}) { + const t = useTranslations(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + async function denyDeviceAuth() { + setLoading(true); + const response = await completeDeviceAuthorization( + deviceAuthorizationRequestId, + ) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response) { + return router.push("/device"); + } + } + + const scopes = scope?.filter((s) => !!s); + + return ( +
+
    + {scopes?.length === 0 && ( + + {t("device.scope.openid")} + + )} + {scopes?.map((s) => { + const translationKey = `device.scope.${s}`; + const description = t(translationKey, null); + + // Check if the key itself is returned and provide a fallback + const resolvedDescription = + description === translationKey ? "" : description; + + return ( +
  • + {resolvedDescription} +
  • + ); + })} +
+ +

+ {t("device.request.disclaimer", { appName: appName })} +

+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/copy-to-clipboard.tsx b/login/apps/login/src/components/copy-to-clipboard.tsx new file mode 100644 index 0000000000..cf0dedc060 --- /dev/null +++ b/login/apps/login/src/components/copy-to-clipboard.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + ClipboardDocumentCheckIcon, + ClipboardIcon, +} from "@heroicons/react/20/solid"; +import copy from "copy-to-clipboard"; +import { useEffect, useState } from "react"; + +type Props = { + value: string; +}; + +export function CopyToClipboard({ value }: Props) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (copied) { + copy(value); + const to = setTimeout(setCopied, 1000, false); + return () => clearTimeout(to); + } + }, [copied]); + + return ( +
+ +
+ ); +} diff --git a/login/apps/login/src/components/default-tags.tsx b/login/apps/login/src/components/default-tags.tsx new file mode 100644 index 0000000000..dc14f1bc1e --- /dev/null +++ b/login/apps/login/src/components/default-tags.tsx @@ -0,0 +1,32 @@ +// Default tags we want shared across the app +export function DefaultTags() { + return ( + <> + + + + + + {/* */} + + + ); +} diff --git a/login/apps/login/src/components/device-code-form.tsx b/login/apps/login/src/components/device-code-form.tsx new file mode 100644 index 0000000000..e09adb1147 --- /dev/null +++ b/login/apps/login/src/components/device-code-form.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Alert } from "@/components/alert"; +import { getDeviceAuthorizationRequest } from "@/lib/server/oidc"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + userCode: string; +}; + +export function DeviceCodeForm({ userCode }: { userCode?: string }) { + const t = useTranslations("verify"); + + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + userCode: userCode || "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function submitCodeAndContinue(value: Inputs): Promise { + setLoading(true); + + const response = await getDeviceAuthorizationRequest(value.userCode) + .catch(() => { + setError("Could not continue the request"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (!response || !response.deviceAuthorizationRequest?.id) { + setError("Could not continue the request"); + return; + } + + return router.push( + `/device/consent?` + + new URLSearchParams({ + requestId: `device_${response.deviceAuthorizationRequest.id}`, + user_code: value.userCode, + }).toString(), + ); + } + + return ( + <> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/login/apps/login/src/components/dynamic-theme.tsx b/login/apps/login/src/components/dynamic-theme.tsx new file mode 100644 index 0000000000..d50bc082ea --- /dev/null +++ b/login/apps/login/src/components/dynamic-theme.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Logo } from "@/components/logo"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { ReactNode } from "react"; +import { AppAvatar } from "./app-avatar"; +import { ThemeWrapper } from "./theme-wrapper"; + +export function DynamicTheme({ + branding, + children, + appName, +}: { + children: ReactNode; + branding?: BrandingSettings; + appName?: string; +}) { + return ( + +
+
+
+ {branding && ( + <> + + + {appName && } + + )} +
+ +
{children}
+
+
+
+
+ ); +} diff --git a/login/apps/login/src/components/external-link.tsx b/login/apps/login/src/components/external-link.tsx new file mode 100644 index 0000000000..a52164d35d --- /dev/null +++ b/login/apps/login/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ArrowRightIcon } from "@heroicons/react/24/solid"; +import { ReactNode } from "react"; + +export const ExternalLink = ({ + children, + href, +}: { + children: ReactNode; + href: string; +}) => { + return ( + +
{children}
+ + +
+ ); +}; diff --git a/login/apps/login/src/components/idp-signin.tsx b/login/apps/login/src/components/idp-signin.tsx new file mode 100644 index 0000000000..a7c938e90c --- /dev/null +++ b/login/apps/login/src/components/idp-signin.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { createNewSessionFromIdpIntent } from "@/lib/server/idp"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Alert } from "./alert"; +import { Spinner } from "./spinner"; + +type Props = { + userId: string; + // organization: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + requestId?: string; +}; + +export function IdpSignin({ + userId, + idpIntent: { idpIntentId, idpIntentToken }, + requestId, +}: Props) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const router = useRouter(); + + useEffect(() => { + createNewSessionFromIdpIntent({ + userId, + idpIntent: { + idpIntentId, + idpIntentToken, + }, + requestId, + }) + .then((response) => { + if (response && "error" in response && response?.error) { + setError(response?.error); + return; + } + + if (response && "redirect" in response && response?.redirect) { + return router.push(response.redirect); + } + }) + .catch(() => { + setError("An internal error occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return ( +
+ {loading && } + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/login/apps/login/src/components/idps/base-button.tsx b/login/apps/login/src/components/idps/base-button.tsx new file mode 100644 index 0000000000..0185c57996 --- /dev/null +++ b/login/apps/login/src/components/idps/base-button.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { clsx } from "clsx"; +import { Loader2Icon } from "lucide-react"; +import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from "react"; +import { useFormStatus } from "react-dom"; + +export type SignInWithIdentityProviderProps = DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement +> & { + name?: string; + e2e?: string; +}; + +export const BaseButton = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function BaseButton(props, ref) { + const formStatus = useFormStatus(); + + return ( + + ); +}); diff --git a/login/apps/login/src/components/idps/pages/complete-idp.tsx b/login/apps/login/src/components/idps/pages/complete-idp.tsx new file mode 100644 index 0000000000..e1ed5e7401 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/complete-idp.tsx @@ -0,0 +1,54 @@ +import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { DynamicTheme } from "../../dynamic-theme"; + +export async function completeIDP({ + idpUserId, + idpId, + idpUserName, + addHumanUser, + requestId, + organization, + branding, + idpIntent, +}: { + idpUserId: string; + idpId: string; + idpUserName: string; + addHumanUser?: AddHumanUserRequest; + requestId?: string; + organization: string; + branding?: BrandingSettings; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; +}) { + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + return ( + +
+

{t("completeRegister.title")}

+

{t("completeRegister.description")}

+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/linking-failed.tsx b/login/apps/login/src/components/idps/pages/linking-failed.tsx new file mode 100644 index 0000000000..f4016a3394 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/linking-failed.tsx @@ -0,0 +1,26 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { Alert, AlertType } from "../../alert"; +import { DynamicTheme } from "../../dynamic-theme"; + +export async function linkingFailed( + branding?: BrandingSettings, + error?: string, +) { + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + return ( + +
+

{t("linkingError.title")}

+

{t("linkingError.description")}

+ {error && ( +
+ {{error}} +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/linking-success.tsx b/login/apps/login/src/components/idps/pages/linking-success.tsx new file mode 100644 index 0000000000..f4faa8e1bf --- /dev/null +++ b/login/apps/login/src/components/idps/pages/linking-success.tsx @@ -0,0 +1,29 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { DynamicTheme } from "../../dynamic-theme"; +import { IdpSignin } from "../../idp-signin"; + +export async function linkingSuccess( + userId: string, + idpIntent: { idpIntentId: string; idpIntentToken: string }, + requestId?: string, + branding?: BrandingSettings, +) { + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + return ( + +
+

{t("linkingSuccess.title")}

+

{t("linkingSuccess.description")}

+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/login-failed.tsx b/login/apps/login/src/components/idps/pages/login-failed.tsx new file mode 100644 index 0000000000..e4187af791 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/login-failed.tsx @@ -0,0 +1,23 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { Alert, AlertType } from "../../alert"; +import { DynamicTheme } from "../../dynamic-theme"; + +export async function loginFailed(branding?: BrandingSettings, error?: string) { + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + return ( + +
+

{t("loginError.title")}

+

{t("loginError.description")}

+ {error && ( +
+ {{error}} +
+ )} +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/pages/login-success.tsx b/login/apps/login/src/components/idps/pages/login-success.tsx new file mode 100644 index 0000000000..6c884873f1 --- /dev/null +++ b/login/apps/login/src/components/idps/pages/login-success.tsx @@ -0,0 +1,29 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { DynamicTheme } from "../../dynamic-theme"; +import { IdpSignin } from "../../idp-signin"; + +export async function loginSuccess( + userId: string, + idpIntent: { idpIntentId: string; idpIntentToken: string }, + requestId?: string, + branding?: BrandingSettings, +) { + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + return ( + +
+

{t("loginSuccess.title")}

+

{t("loginSuccess.description")}

+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/idps/sign-in-with-apple.tsx b/login/apps/login/src/components/idps/sign-in-with-apple.tsx new file mode 100644 index 0000000000..8b9419ae02 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-apple.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithApple = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithApple(props, ref) { + const { children, name, ...restProps } = props; + const t = useTranslations("idp"); + + return ( + +
+
+ + Apple Logo + + +
+
+ {children ? ( + children + ) : ( + {name ? name : t("signInWithApple")} + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx b/login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx new file mode 100644 index 0000000000..a3a4c82272 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithAzureAd = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithAzureAd(props, ref) { + const { children, name, ...restProps } = props; + const t = useTranslations("idp"); + + return ( + +
+ + + + + + +
+ {children ? ( + children + ) : ( + {name ? name : t("signInWithAzureAD")} + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-generic.tsx b/login/apps/login/src/components/idps/sign-in-with-generic.tsx new file mode 100644 index 0000000000..ab8f2f99be --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-generic.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithGeneric = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGeneric(props, ref) { + const { + children, + name = "", + className = "h-[50px] pl-20", + ...restProps + } = props; + return ( + + {children ? children : {name}} + + ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-github.tsx b/login/apps/login/src/components/idps/sign-in-with-github.tsx new file mode 100644 index 0000000000..45108d17f7 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-github.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +function GitHubLogo() { + return ( + <> + + + + + + + + ); +} + +export const SignInWithGithub = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGithub(props, ref) { + const { children, name, ...restProps } = props; + const t = useTranslations("idp"); + + return ( + +
+ +
+ {children ? ( + children + ) : ( + {name ? name : t("signInWithGithub")} + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx b/login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx new file mode 100644 index 0000000000..ab5bfda54d --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { cleanup, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; + +import { SignInWithGitlab } from "./sign-in-with-gitlab"; + +afterEach(cleanup); + +describe("", async () => { + const messages = { + idp: { + signInWithGitlab: "Sign in with GitLab", + }, + }; + + test("renders without crashing", () => { + const { container } = render( + + + , + ); + expect(container.firstChild).toBeDefined(); + }); + + test("displays the default text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Sign in with Gitlab/i); + expect(signInText).toBeInTheDocument(); + }); + + test("displays the given text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Gitlab/i); + expect(signInText).toBeInTheDocument(); + }); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-gitlab.tsx b/login/apps/login/src/components/idps/sign-in-with-gitlab.tsx new file mode 100644 index 0000000000..8a1ed7d349 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-gitlab.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithGitlab = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGitlab(props, ref) { + const { children, name, ...restProps } = props; + const t = useTranslations("idp"); + + return ( + +
+ + + + + + +
+ {children ? ( + children + ) : ( + {name ? name : t("signInWithGitlab")} + )} +
+ ); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-google.test.tsx b/login/apps/login/src/components/idps/sign-in-with-google.test.tsx new file mode 100644 index 0000000000..953da21d94 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-google.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { cleanup, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import { SignInWithGoogle } from "./sign-in-with-google"; + +afterEach(cleanup); + +describe("", async () => { + const messages = { + idp: { + signInWithGoogle: "Sign in with Google", + }, + }; + + test("renders without crashing", () => { + const { container } = render( + + + , + ); + expect(container.firstChild).toBeDefined(); + }); + + test("displays the default text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Sign in with Google/i); + expect(signInText).toBeInTheDocument(); + }); + + test("displays the given text", () => { + render( + + + , + ); + const signInText = screen.getByText(/Google/i); + expect(signInText).toBeInTheDocument(); + }); +}); diff --git a/login/apps/login/src/components/idps/sign-in-with-google.tsx b/login/apps/login/src/components/idps/sign-in-with-google.tsx new file mode 100644 index 0000000000..6162ef4a96 --- /dev/null +++ b/login/apps/login/src/components/idps/sign-in-with-google.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { forwardRef } from "react"; +import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; + +export const SignInWithGoogle = forwardRef< + HTMLButtonElement, + SignInWithIdentityProviderProps +>(function SignInWithGoogle(props, ref) { + const { children, name, ...restProps } = props; + const t = useTranslations("idp"); + + return ( + +
+ + + + + + + +
+ {children ? ( + children + ) : ( + {name ? name : t("signInWithGoogle")} + )} +
+ ); +}); diff --git a/login/apps/login/src/components/input.tsx b/login/apps/login/src/components/input.tsx new file mode 100644 index 0000000000..de19156b91 --- /dev/null +++ b/login/apps/login/src/components/input.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import { clsx } from "clsx"; +import { + ChangeEvent, + DetailedHTMLProps, + forwardRef, + InputHTMLAttributes, + ReactNode, +} from "react"; + +export type TextInputProps = DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement +> & { + label: string; + suffix?: string; + placeholder?: string; + defaultValue?: string; + error?: string | ReactNode; + success?: string | ReactNode; + disabled?: boolean; + onChange?: (value: ChangeEvent) => void; + onBlur?: (value: ChangeEvent) => void; +}; + +const styles = (error: boolean, disabled: boolean) => + clsx({ + "h-[40px] mb-[2px] rounded p-[7px] bg-input-light-background dark:bg-input-dark-background transition-colors duration-300 grow": + true, + "border border-input-light-border dark:border-input-dark-border hover:border-black hover:dark:border-white focus:border-primary-light-500 focus:dark:border-primary-dark-500": + true, + "focus:outline-none focus:ring-0 text-base text-black dark:text-white placeholder:italic placeholder-gray-700 dark:placeholder-gray-700": + true, + "border border-warn-light-500 dark:border-warn-dark-500 hover:border-warn-light-500 hover:dark:border-warn-dark-500 focus:border-warn-light-500 focus:dark:border-warn-dark-500": + error, + "pointer-events-none text-gray-500 dark:text-gray-800 border border-input-light-border dark:border-input-dark-border hover:border-light-hoverborder hover:dark:border-hoverborder cursor-default": + disabled, + }); + +// eslint-disable-next-line react/display-name +export const TextInput = forwardRef( + ( + { + label, + placeholder, + defaultValue, + suffix, + required = false, + error, + disabled, + success, + onChange, + onBlur, + ...props + }, + ref, + ) => { + return ( + + ); + }, +); diff --git a/login/apps/login/src/components/language-provider.tsx b/login/apps/login/src/components/language-provider.tsx new file mode 100644 index 0000000000..21a53093bb --- /dev/null +++ b/login/apps/login/src/components/language-provider.tsx @@ -0,0 +1,13 @@ +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import { ReactNode } from "react"; + +export async function LanguageProvider({ children }: { children: ReactNode }) { + const messages = await getMessages(); + + return ( + + {children} + + ); +} diff --git a/login/apps/login/src/components/language-switcher.tsx b/login/apps/login/src/components/language-switcher.tsx new file mode 100644 index 0000000000..67b54e58e3 --- /dev/null +++ b/login/apps/login/src/components/language-switcher.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { setLanguageCookie } from "@/lib/cookies"; +import { Lang, LANGS } from "@/lib/i18n"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function LanguageSwitcher() { + const currentLocale = useLocale(); + + const [selected, setSelected] = useState( + LANGS.find((l) => l.code === currentLocale) || LANGS[0], + ); + + const router = useRouter(); + + const handleChange = async (language: Lang) => { + setSelected(language); + const newLocale = language.code; + + await setLanguageCookie(newLocale); + + router.refresh(); + }; + + return ( +
+ + + {selected.name} + + + {LANGS.map((lang, index) => ( + + +
+ {lang.name} +
+
+ ))} +
+
+
+ ); +} diff --git a/login/apps/login/src/components/layout-providers.tsx b/login/apps/login/src/components/layout-providers.tsx new file mode 100644 index 0000000000..fee93d015e --- /dev/null +++ b/login/apps/login/src/components/layout-providers.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; +}; + +export function LayoutProviders({ children }: Props) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( +
{children}
+ ); +} diff --git a/login/apps/login/src/components/login-otp.tsx b/login/apps/login/src/components/login-otp.tsx new file mode 100644 index 0000000000..f8500f6909 --- /dev/null +++ b/login/apps/login/src/components/login-otp.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { getNextUrl } from "@/lib/client"; +import { updateSession } from "@/lib/server/session"; +import { create } from "@zitadel/client"; +import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +// either loginName or sessionId must be provided +type Props = { + host: string | null; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + method: string; + code?: string; + loginSettings?: LoginSettings; +}; + +type Inputs = { + code: string; +}; + +export function LoginOTP({ + host, + loginName, + sessionId, + requestId, + organization, + method, + code, + loginSettings, +}: Props) { + const t = useTranslations("otp"); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + const initialized = useRef(false); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ? code : "", + }, + }); + + useEffect(() => { + if (!initialized.current && ["email", "sms"].includes(method) && !code) { + initialized.current = true; + setLoading(true); + updateSessionForOTPChallenge() + .catch((error) => { + setError(error); + return; + }) + .finally(() => { + setLoading(false); + }); + } + }, []); + + async function updateSessionForOTPChallenge() { + let challenges; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + if (method === "email") { + challenges = create(RequestChallengesSchema, { + otpEmail: { + deliveryType: { + case: "sendCode", + value: host + ? { + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/otp/${method}?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}` + + (requestId ? `&requestId=${requestId}` : ""), + } + : {}, + }, + }, + }); + } + + if (method === "sms") { + challenges = create(RequestChallengesSchema, { + otpSms: {}, + }); + } + + setLoading(true); + const response = await updateSession({ + loginName, + sessionId, + organization, + challenges, + requestId, + }) + .catch(() => { + setError("Could not request OTP challenge"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + return response; + } + + async function submitCode(values: Inputs, organization?: string) { + setLoading(true); + + let body: any = { + code: values.code, + method, + }; + + if (organization) { + body.organization = organization; + } + + if (requestId) { + body.requestId = requestId; + } + + let checks; + + if (method === "sms") { + checks = create(ChecksSchema, { + otpSms: { code: values.code }, + }); + } + if (method === "email") { + checks = create(ChecksSchema, { + otpEmail: { code: values.code }, + }); + } + if (method === "time-based") { + checks = create(ChecksSchema, { + totp: { code: values.code }, + }); + } + + const response = await updateSession({ + loginName, + sessionId, + organization, + checks, + requestId, + }) + .catch(() => { + setError("Could not verify OTP code"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + return response; + } + + function setCodeAndContinue(values: Inputs, organization?: string) { + return submitCode(values, organization).then(async (response) => { + if (response && "sessionId" in response) { + setLoading(true); + // Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const url = + requestId && response.sessionId + ? await getNextUrl( + { + sessionId: response.sessionId, + requestId: requestId, + organization: response.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : response.factors?.user + ? await getNextUrl( + { + loginName: response.factors.user.loginName, + organization: response.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + setLoading(false); + if (url) { + router.push(url); + } + } + }); + } + + return ( +
+ {["email", "sms"].includes(method) && ( + +
+ + {t("verify.noCodeReceived")} + + +
+
+ )} +
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/login-passkey.tsx b/login/apps/login/src/components/login-passkey.tsx new file mode 100644 index 0000000000..b3f0b1212f --- /dev/null +++ b/login/apps/login/src/components/login-passkey.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; +import { sendPasskey } from "@/lib/server/passkeys"; +import { updateSession } from "@/lib/server/session"; +import { create, JsonObject } from "@zitadel/client"; +import { + RequestChallengesSchema, + UserVerificationRequirement, +} from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; + +// either loginName or sessionId must be provided +type Props = { + loginName?: string; + sessionId?: string; + requestId?: string; + altPassword: boolean; + login?: boolean; + organization?: string; +}; + +export function LoginPasskey({ + loginName, + sessionId, + requestId, + altPassword, + organization, + login = true, +}: Props) { + const t = useTranslations("passkey"); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + const initialized = useRef(false); + + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + setLoading(true); + updateSessionForChallenge() + .then((response) => { + const pK = + response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions + ?.publicKey; + + if (!pK) { + setError("Could not request passkey challenge"); + setLoading(false); + return; + } + + return submitLoginAndContinue(pK) + .catch((error) => { + setError(error); + return; + }) + .finally(() => { + setLoading(false); + }); + }) + .catch((error) => { + setError(error); + return; + }) + .finally(() => { + setLoading(false); + }); + } + }, []); + + async function updateSessionForChallenge( + userVerificationRequirement: number = login + ? UserVerificationRequirement.REQUIRED + : UserVerificationRequirement.DISCOURAGED, + ) { + setError(""); + setLoading(true); + const session = await updateSession({ + loginName, + sessionId, + organization, + challenges: create(RequestChallengesSchema, { + webAuthN: { + domain: "", + userVerificationRequirement, + }, + }), + requestId, + }) + .catch(() => { + setError("Could not request passkey challenge"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (session && "error" in session && session.error) { + setError(session.error); + return; + } + + return session; + } + + async function submitLogin(data: JsonObject) { + setLoading(true); + const response = await sendPasskey({ + loginName, + sessionId, + organization, + checks: { + webAuthN: { credentialAssertionData: data }, + } as Checks, + requestId, + }) + .catch(() => { + setError("Could not verify passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + async function submitLoginAndContinue( + publicKey: any, + ): Promise { + publicKey.challenge = coerceToArrayBuffer( + publicKey.challenge, + "publicKey.challenge", + ); + publicKey.allowCredentials.map((listItem: any) => { + listItem.id = coerceToArrayBuffer( + listItem.id, + "publicKey.allowCredentials.id", + ); + }); + + navigator.credentials + .get({ + publicKey, + }) + .then((assertedCredential: any) => { + if (!assertedCredential) { + setError("An error on retrieving passkey"); + return; + } + + const authData = new Uint8Array( + assertedCredential.response.authenticatorData, + ); + const clientDataJSON = new Uint8Array( + assertedCredential.response.clientDataJSON, + ); + const rawId = new Uint8Array(assertedCredential.rawId); + const sig = new Uint8Array(assertedCredential.response.signature); + const userHandle = new Uint8Array( + assertedCredential.response.userHandle, + ); + const data = { + id: assertedCredential.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: assertedCredential.type, + response: { + authenticatorData: coerceToBase64Url(authData, "authData"), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + signature: coerceToBase64Url(sig, "sig"), + userHandle: coerceToBase64Url(userHandle, "userHandle"), + }, + }; + + return submitLogin(data); + }) + .finally(() => { + setLoading(false); + }); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ {altPassword ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/logo.tsx b/login/apps/login/src/components/logo.tsx new file mode 100644 index 0000000000..09819f2ac3 --- /dev/null +++ b/login/apps/login/src/components/logo.tsx @@ -0,0 +1,37 @@ +import Image from "next/image"; + +type Props = { + darkSrc?: string; + lightSrc?: string; + height?: number; + width?: number; +}; + +export function Logo({ lightSrc, darkSrc, height = 40, width = 147.5 }: Props) { + return ( + <> + {darkSrc && ( +
+ logo +
+ )} + {lightSrc && ( +
+ logo +
+ )} + + ); +} diff --git a/login/apps/login/src/components/password-complexity.test.tsx b/login/apps/login/src/components/password-complexity.test.tsx new file mode 100644 index 0000000000..090c95d397 --- /dev/null +++ b/login/apps/login/src/components/password-complexity.test.tsx @@ -0,0 +1,64 @@ +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { PasswordComplexity } from "./password-complexity"; + +const matchesTitle = `Matches`; +const doesntMatchTitle = `Doesn't match`; + +describe("", () => { + describe.each` + settingsMinLength | password | expectSVGTitle + ${5} | ${"Password1!"} | ${matchesTitle} + ${30} | ${"Password1!"} | ${doesntMatchTitle} + ${0} | ${"Password1!"} | ${matchesTitle} + ${undefined} | ${"Password1!"} | ${false} + `( + `With settingsMinLength=$settingsMinLength, password=$password, expectSVGTitle=$expectSVGTitle`, + ({ settingsMinLength, password, expectSVGTitle }) => { + const feedbackElementLabel = /password length/i; + beforeEach(() => { + render( + , + ); + }); + afterEach(cleanup); + + if (expectSVGTitle === false) { + test(`should not render the feedback element`, async () => { + await waitFor(() => { + expect( + screen.queryByText(feedbackElementLabel), + ).not.toBeInTheDocument(); + }); + }); + } else { + test(`Should show one SVG with title ${expectSVGTitle}`, async () => { + await waitFor(async () => { + const svg = within( + screen.getByText(feedbackElementLabel) + .parentElement as HTMLElement, + ).findByRole("img"); + expect(await svg).toHaveTextContent(expectSVGTitle); + }); + }); + } + }, + ); +}); diff --git a/login/apps/login/src/components/password-complexity.tsx b/login/apps/login/src/components/password-complexity.tsx new file mode 100644 index 0000000000..40988984b6 --- /dev/null +++ b/login/apps/login/src/components/password-complexity.tsx @@ -0,0 +1,99 @@ +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; + +type Props = { + passwordComplexitySettings: PasswordComplexitySettings; + password: string; + equals: boolean; +}; + +const check = ( + + Matches + + +); +const cross = ( + + Doesn't match + + +); +const desc = + "text-14px leading-4 text-input-light-label dark:text-input-dark-label"; + +export function PasswordComplexity({ + passwordComplexitySettings, + password, + equals, +}: Props) { + const hasMinLength = password?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(password); + const hasNumber = numberValidator(password); + const hasUppercase = upperCaseValidator(password); + const hasLowercase = lowerCaseValidator(password); + + return ( +
+ {passwordComplexitySettings.minLength != undefined ? ( +
+ {hasMinLength ? check : cross} + + Password length {passwordComplexitySettings.minLength.toString()} + +
+ ) : ( + + )} +
+ {hasSymbol ? check : cross} + has Symbol +
+
+ {hasNumber ? check : cross} + has Number +
+
+ {hasUppercase ? check : cross} + has uppercase +
+
+ {hasLowercase ? check : cross} + has lowercase +
+
+ {equals ? check : cross} + equals +
+
+ ); +} diff --git a/login/apps/login/src/components/password-form.tsx b/login/apps/login/src/components/password-form.tsx new file mode 100644 index 0000000000..17461644d8 --- /dev/null +++ b/login/apps/login/src/components/password-form.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { resetPassword, sendPassword } from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + password: string; +}; + +type Props = { + loginSettings: LoginSettings | undefined; + loginName: string; + organization?: string; + requestId?: string; + isAlternative?: boolean; // whether password was requested as alternative auth method + promptPasswordless?: boolean; +}; + +export function PasswordForm({ + loginSettings, + loginName, + organization, + requestId, + promptPasswordless, + isAlternative, +}: Props) { + const t = useTranslations("password"); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [info, setInfo] = useState(""); + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitPassword(values: Inputs) { + setError(""); + setLoading(true); + + const response = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + async function resetPasswordAndContinue() { + setError(""); + setInfo(""); + setLoading(true); + + const response = await resetPassword({ + loginName, + organization, + requestId, + }) + .catch(() => { + setError("Could not reset password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response) { + setError(response.error); + return; + } + + setInfo("Password was reset. Please check your email."); + + const params = new URLSearchParams({ + loginName: loginName, + }); + + if (organization) { + params.append("organization", organization); + } + + if (requestId) { + params.append("requestId", requestId); + } + + return router.push("/password/set?" + params); + } + + return ( +
+
+ + {!loginSettings?.hidePasswordReset && ( + + )} + + {loginName && ( + + )} +
+ + {info && ( +
+ {info} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/privacy-policy-checkboxes.tsx b/login/apps/login/src/components/privacy-policy-checkboxes.tsx new file mode 100644 index 0000000000..5ac0340bcb --- /dev/null +++ b/login/apps/login/src/components/privacy-policy-checkboxes.tsx @@ -0,0 +1,106 @@ +"use client"; +import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useState } from "react"; +import { Checkbox } from "./checkbox"; + +type Props = { + legal: LegalAndSupportSettings; + onChange: (allAccepted: boolean) => void; +}; + +type AcceptanceState = { + tosAccepted: boolean; + privacyPolicyAccepted: boolean; +}; + +export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { + const t = useTranslations("register"); + const [acceptanceState, setAcceptanceState] = useState({ + tosAccepted: false, + privacyPolicyAccepted: false, + }); + + return ( + <> +

+ {t("agreeTo")} + {legal?.helpLink && ( + + + + + + + + )} +

+ {legal?.tosLink && ( +
+ { + setAcceptanceState({ + ...acceptanceState, + tosAccepted: checked, + }); + onChange(checked && acceptanceState.privacyPolicyAccepted); + }} + data-testid="privacy-policy-checkbox" + /> + +
+

+ + {t("termsOfService")} + +

+
+
+ )} + {legal?.privacyPolicyLink && ( +
+ { + setAcceptanceState({ + ...acceptanceState, + privacyPolicyAccepted: checked, + }); + onChange(checked && acceptanceState.tosAccepted); + }} + data-testid="tos-checkbox" + /> + +
+

+ + {t("privacyPolicy")} + +

+
+
+ )} + + ); +} diff --git a/login/apps/login/src/components/register-form-idp-incomplete.tsx b/login/apps/login/src/components/register-form-idp-incomplete.tsx new file mode 100644 index 0000000000..6194b34052 --- /dev/null +++ b/login/apps/login/src/components/register-form-idp-incomplete.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { registerUserAndLinkToIDP } from "@/lib/server/register"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = + | { + firstname: string; + lastname: string; + email: string; + } + | FieldValues; + +type Props = { + organization: string; + requestId?: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + defaultValues?: { + firstname?: string; + lastname?: string; + email?: string; + }; + idpUserId: string; + idpId: string; + idpUserName: string; +}; + +export function RegisterFormIDPIncomplete({ + organization, + requestId, + idpIntent, + defaultValues, + idpUserId, + idpId, + idpUserName, +}: Props) { + const t = useTranslations("register"); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + email: defaultValues?.email ?? "", + firstname: defaultValues?.firstname ?? "", + lastname: defaultValues?.lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function submitAndRegister(values: Inputs) { + setLoading(true); + const response = await registerUserAndLinkToIDP({ + idpId: idpId, + idpUserName: idpUserName, + idpUserId: idpUserId, + email: values.email, + firstName: values.firstname, + lastName: values.lastname, + organization: organization, + requestId: requestId, + idpIntent: idpIntent, + }) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + + return response; + } + + const { errors } = formState; + + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ); +} diff --git a/login/apps/login/src/components/register-form.tsx b/login/apps/login/src/components/register-form.tsx new file mode 100644 index 0000000000..c581131c8c --- /dev/null +++ b/login/apps/login/src/components/register-form.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { registerUser } from "@/lib/server/register"; +import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; +import { + LoginSettings, + PasskeysType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { + AuthenticationMethod, + AuthenticationMethodRadio, + methods, +} from "./authentication-method-radio"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes"; +import { Spinner } from "./spinner"; + +type Inputs = + | { + firstname: string; + lastname: string; + email: string; + } + | FieldValues; + +type Props = { + legal: LegalAndSupportSettings; + firstname?: string; + lastname?: string; + email?: string; + organization: string; + requestId?: string; + loginSettings?: LoginSettings; + idpCount: number; +}; + +export function RegisterForm({ + legal, + email, + firstname, + lastname, + organization, + requestId, + loginSettings, + idpCount = 0, +}: Props) { + const t = useTranslations("register"); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + email: email ?? "", + firstName: firstname ?? "", + lastname: lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(methods[0]); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function submitAndRegister(values: Inputs) { + setLoading(true); + const response = await registerUser({ + email: values.email, + firstName: values.firstname, + lastName: values.lastname, + organization: organization, + requestId: requestId, + }) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + + return response; + } + + async function submitAndContinue( + value: Inputs, + withPassword: boolean = false, + ) { + const registerParams: any = value; + + if (organization) { + registerParams.organization = organization; + } + + if (requestId) { + registerParams.requestId = requestId; + } + + // redirect user to /register/password if password is chosen + if (withPassword) { + return router.push( + `/register/password?` + new URLSearchParams(registerParams), + ); + } else { + return submitAndRegister(value); + } + } + + const { errors } = formState; + + const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false); + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ {legal && ( + + )} + {/* show chooser if both methods are allowed */} + {loginSettings && + loginSettings.allowUsernamePassword && + loginSettings.passkeysType == PasskeysType.ALLOWED && ( + <> +

+ {t("selectMethod")} +

+ +
+ +
+ + )} + + {!loginSettings?.allowUsernamePassword && + loginSettings?.passkeysType != PasskeysType.ALLOWED && + (!loginSettings?.allowExternalIdp || !idpCount) && ( +
+ {t("noMethodAvailableWarning")} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/register-passkey.tsx b/login/apps/login/src/components/register-passkey.tsx new file mode 100644 index 0000000000..8687312bbc --- /dev/null +++ b/login/apps/login/src/components/register-passkey.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; +import { + registerPasskeyLink, + verifyPasskeyRegistration, +} from "@/lib/server/passkeys"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; + +type Inputs = {}; + +type Props = { + sessionId: string; + isPrompt: boolean; + requestId?: string; + organization?: string; +}; + +export function RegisterPasskey({ + sessionId, + isPrompt, + organization, + requestId, +}: Props) { + const t = useTranslations("passkey"); + + const { handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitVerify( + passkeyId: string, + passkeyName: string, + publicKeyCredential: any, + sessionId: string, + ) { + setLoading(true); + const response = await verifyPasskeyRegistration({ + passkeyId, + passkeyName, + publicKeyCredential, + sessionId, + }) + .catch(() => { + setError("Could not verify Passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + return response; + } + + async function submitRegisterAndContinue(): Promise { + setLoading(true); + const resp = await registerPasskeyLink({ + sessionId, + }) + .catch(() => { + setError("Could not register passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (!resp) { + setError("An error on registering passkey"); + return; + } + + if ("error" in resp && resp.error) { + setError(resp.error); + return; + } + + if (!("passkeyId" in resp)) { + setError("An error on registering passkey"); + return; + } + + const passkeyId = resp.passkeyId; + const options: CredentialCreationOptions = + (resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? + {}; + + if (!options.publicKey) { + setError("An error on registering passkey"); + return; + } + + options.publicKey.challenge = coerceToArrayBuffer( + options.publicKey.challenge, + "challenge", + ); + options.publicKey.user.id = coerceToArrayBuffer( + options.publicKey.user.id, + "userid", + ); + if (options.publicKey.excludeCredentials) { + options.publicKey.excludeCredentials.map((cred: any) => { + cred.id = coerceToArrayBuffer( + cred.id as string, + "excludeCredentials.id", + ); + return cred; + }); + } + + const credentials = await navigator.credentials.create(options); + + if ( + !credentials || + !(credentials as any).response?.attestationObject || + !(credentials as any).response?.clientDataJSON || + !(credentials as any).rawId + ) { + setError("An error on registering passkey"); + return; + } + + const attestationObject = (credentials as any).response.attestationObject; + const clientDataJSON = (credentials as any).response.clientDataJSON; + const rawId = (credentials as any).rawId; + + const data = { + id: credentials.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: credentials.type, + response: { + attestationObject: coerceToBase64Url( + attestationObject, + "attestationObject", + ), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + }, + }; + + const verificationResponse = await submitVerify( + passkeyId, + "", + data, + sessionId, + ); + + if (!verificationResponse) { + setError("Could not verify Passkey!"); + return; + } + + continueAndLogin(); + } + + function continueAndLogin() { + const params = new URLSearchParams(); + + if (organization) { + params.set("organization", organization); + } + + if (requestId) { + params.set("requestId", requestId); + } + + params.set("sessionId", sessionId); + + router.push("/passkey?" + params); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ {isPrompt ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/register-u2f.tsx b/login/apps/login/src/components/register-u2f.tsx new file mode 100644 index 0000000000..753eae017d --- /dev/null +++ b/login/apps/login/src/components/register-u2f.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; +import { getNextUrl } from "@/lib/client"; +import { addU2F, verifyU2F } from "@/lib/server/u2f"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { Spinner } from "./spinner"; + +type Props = { + loginName?: string; + sessionId: string; + requestId?: string; + organization?: string; + checkAfter: boolean; + loginSettings?: LoginSettings; +}; + +export function RegisterU2f({ + loginName, + sessionId, + organization, + requestId, + checkAfter, + loginSettings, +}: Props) { + const t = useTranslations("u2f"); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitVerify( + u2fId: string, + passkeyName: string, + publicKeyCredential: any, + sessionId: string, + ) { + setError(""); + setLoading(true); + const response = await verifyU2F({ + u2fId, + passkeyName, + publicKeyCredential, + sessionId, + }) + .catch(() => { + setError("An error on verifying passkey occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response?.error); + return; + } + + return response; + } + + async function submitRegisterAndContinue(): Promise { + setError(""); + setLoading(true); + const response = await addU2F({ + sessionId, + }) + .catch(() => { + setError("An error on registering passkey"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response?.error); + return; + } + + if (!response || !("u2fId" in response)) { + setError("An error on registering passkey"); + return; + } + + const u2fResponse = response as unknown as RegisterU2FResponse; + + const u2fId = u2fResponse.u2fId; + const options: CredentialCreationOptions = + (u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? + {}; + + if (options.publicKey) { + options.publicKey.challenge = coerceToArrayBuffer( + options.publicKey.challenge, + "challenge", + ); + options.publicKey.user.id = coerceToArrayBuffer( + options.publicKey.user.id, + "userid", + ); + if (options.publicKey.excludeCredentials) { + options.publicKey.excludeCredentials.map((cred: any) => { + cred.id = coerceToArrayBuffer( + cred.id as string, + "excludeCredentials.id", + ); + return cred; + }); + } + + const resp = await navigator.credentials.create(options); + + if ( + !resp || + !(resp as any).response.attestationObject || + !(resp as any).response.clientDataJSON || + !(resp as any).rawId + ) { + setError("An error on registering passkey"); + return; + } + + const attestationObject = (resp as any).response.attestationObject; + const clientDataJSON = (resp as any).response.clientDataJSON; + const rawId = (resp as any).rawId; + + const data = { + id: resp.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: resp.type, + response: { + attestationObject: coerceToBase64Url( + attestationObject, + "attestationObject", + ), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + }, + }; + + const submitResponse = await submitVerify(u2fId, "", data, sessionId); + + if (!submitResponse) { + setError("An error on verifying passkey"); + return; + } + + if (checkAfter) { + const paramsToContinue = new URLSearchParams({}); + + if (sessionId) { + paramsToContinue.append("sessionId", sessionId); + } + if (loginName) { + paramsToContinue.append("loginName", loginName); + } + if (organization) { + paramsToContinue.append("organization", organization); + } + if (requestId) { + paramsToContinue.append("requestId", requestId); + } + + return router.push(`/u2f?` + paramsToContinue); + } else { + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return router.push(url); + } + } + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + + + +
+
+ ); +} diff --git a/login/apps/login/src/components/self-service-menu.tsx b/login/apps/login/src/components/self-service-menu.tsx new file mode 100644 index 0000000000..449c1dda1f --- /dev/null +++ b/login/apps/login/src/components/self-service-menu.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +export function SelfServiceMenu({ sessionId }: { sessionId: string }) { + const list: any[] = []; + + // if (!!config.selfservice.change_password.enabled) { + // list.push({ + // link: + // `/me/change-password?` + + // new URLSearchParams({ + // sessionId: sessionId, + // }), + // name: "Change password", + // }); + // } + + return ( +
+ {list.map((menuitem, index) => { + return ( + + ); + })} +
+ ); +} + +const SelfServiceItem = ({ name, link }: { name: string; link: string }) => { + return ( + + {name} + + ); +}; diff --git a/login/apps/login/src/components/session-clear-item.tsx b/login/apps/login/src/components/session-clear-item.tsx new file mode 100644 index 0000000000..8bff33fee9 --- /dev/null +++ b/login/apps/login/src/components/session-clear-item.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { clearSession } from "@/lib/server/session"; +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import moment from "moment"; +import { useLocale, useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Avatar } from "./avatar"; +import { isSessionValid } from "./session-item"; + +export function SessionClearItem({ + session, + reload, +}: { + session: Session; + reload: () => void; +}) { + const t = useTranslations("logout"); + + const currentLocale = useLocale(); + moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); + + const [loading, setLoading] = useState(false); + + async function clearSessionId(id: string) { + setLoading(true); + const response = await clearSession({ + sessionId: id, + }) + .catch((error) => { + setError(error.message); + return; + }) + .finally(() => { + setLoading(false); + }); + + return response; + } + + const { valid, verifiedAt } = isSessionValid(session); + + const [error, setError] = useState(null); + + const router = useRouter(); + + return ( + + ); +} diff --git a/login/apps/login/src/components/session-item.tsx b/login/apps/login/src/components/session-item.tsx new file mode 100644 index 0000000000..94e7a19da5 --- /dev/null +++ b/login/apps/login/src/components/session-item.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { sendLoginname } from "@/lib/server/loginname"; +import { clearSession, continueWithSession } from "@/lib/server/session"; +import { XCircleIcon } from "@heroicons/react/24/outline"; +import { Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import moment from "moment"; +import { useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Avatar } from "./avatar"; + +export function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const validIDP = session?.factors?.intent?.verifiedAt; + + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey || validIDP; + const valid = !!((validPassword || validPasskey || validIDP) && stillValid); + + return { valid, verifiedAt }; +} + +export function SessionItem({ + session, + reload, + requestId, +}: { + session: Session; + reload: () => void; + requestId?: string; +}) { + const currentLocale = useLocale(); + moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); + + const [loading, setLoading] = useState(false); + + async function clearSessionId(id: string) { + setLoading(true); + const response = await clearSession({ + sessionId: id, + }) + .catch((error) => { + setError(error.message); + return; + }) + .finally(() => { + setLoading(false); + }); + + return response; + } + + const { valid, verifiedAt } = isSessionValid(session); + + const [error, setError] = useState(null); + + const router = useRouter(); + + return ( + + ); +} diff --git a/login/apps/login/src/components/sessions-clear-list.tsx b/login/apps/login/src/components/sessions-clear-list.tsx new file mode 100644 index 0000000000..fe7a67f746 --- /dev/null +++ b/login/apps/login/src/components/sessions-clear-list.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { clearSession } from "@/lib/server/session"; +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { useTranslations } from "next-intl"; +import { redirect, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Alert, AlertType } from "./alert"; +import { SessionClearItem } from "./session-clear-item"; + +type Props = { + sessions: Session[]; + postLogoutRedirectUri?: string; + logoutHint?: string; + organization?: string; +}; + +export function SessionsClearList({ + sessions, + logoutHint, + postLogoutRedirectUri, + organization, +}: Props) { + const t = useTranslations("logout"); + const [list, setList] = useState(sessions); + const router = useRouter(); + + async function clearHintedSession() { + console.log("Clearing session for login hint:", logoutHint); + // If a login hint is provided, we logout that specific session + const sessionIdToBeCleared = sessions.find((session) => { + return session.factors?.user?.loginName === logoutHint; + })?.id; + + if (sessionIdToBeCleared) { + const clearSessionResponse = await clearSession({ + sessionId: sessionIdToBeCleared, + }).catch((error) => { + console.error("Error clearing session:", error); + return; + }); + + if (!clearSessionResponse) { + console.error("Failed to clear session for login hint:", logoutHint); + } + + if (postLogoutRedirectUri) { + return redirect(postLogoutRedirectUri); + } + + const params = new URLSearchParams(); + + if (organization) { + params.set("organization", organization); + } + + return router.push("/logout/success?" + params); + } else { + console.warn(`No session found for login hint: ${logoutHint}`); + } + } + + useEffect(() => { + if (logoutHint) { + clearHintedSession(); + } + }, []); + + return sessions ? ( +
+ {list + .filter((session) => session?.factors?.user?.loginName) + // sort by change date descending + .sort((a, b) => { + const dateA = a.changeDate + ? timestampDate(a.changeDate).getTime() + : 0; + const dateB = b.changeDate + ? timestampDate(b.changeDate).getTime() + : 0; + return dateB - dateA; + }) + // TODO: add sorting to move invalid sessions to the bottom + .map((session, index) => { + return ( + { + setList(list.filter((s) => s.id !== session.id)); + if (postLogoutRedirectUri) { + router.push(postLogoutRedirectUri); + } + }} + key={"session-" + index} + /> + ); + })} + {list.length === 0 && ( + {t("noResults")} + )} +
+ ) : ( + {t("noResults")} + ); +} diff --git a/login/apps/login/src/components/sessions-list.tsx b/login/apps/login/src/components/sessions-list.tsx new file mode 100644 index 0000000000..50f621a62d --- /dev/null +++ b/login/apps/login/src/components/sessions-list.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Alert } from "./alert"; +import { SessionItem } from "./session-item"; + +type Props = { + sessions: Session[]; + requestId?: string; +}; + +export function SessionsList({ sessions, requestId }: Props) { + const t = useTranslations("accounts"); + const [list, setList] = useState(sessions); + return sessions ? ( +
+ {list + .filter((session) => session?.factors?.user?.loginName) + // sort by change date descending + .sort((a, b) => { + const dateA = a.changeDate + ? timestampDate(a.changeDate).getTime() + : 0; + const dateB = b.changeDate + ? timestampDate(b.changeDate).getTime() + : 0; + return dateB - dateA; + }) + // TODO: add sorting to move invalid sessions to the bottom + .map((session, index) => { + return ( + { + setList(list.filter((s) => s.id !== session.id)); + }} + key={"session-" + index} + /> + ); + })} +
+ ) : ( + {t("noResults")} + ); +} diff --git a/login/apps/login/src/components/set-password-form.tsx b/login/apps/login/src/components/set-password-form.tsx new file mode 100644 index 0000000000..08f5c7c4ef --- /dev/null +++ b/login/apps/login/src/components/set-password-form.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { + changePassword, + resetPassword, + sendPassword, +} from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert, AlertType } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PasswordComplexity } from "./password-complexity"; +import { Spinner } from "./spinner"; + +type Inputs = + | { + code: string; + password: string; + confirmPassword: string; + } + | FieldValues; + +type Props = { + code?: string; + passwordComplexitySettings: PasswordComplexitySettings; + loginName: string; + userId: string; + organization?: string; + requestId?: string; + codeRequired: boolean; +}; + +export function SetPasswordForm({ + passwordComplexitySettings, + organization, + requestId, + loginName, + userId, + code, + codeRequired, +}: Props) { + const t = useTranslations("password"); + + const { register, handleSubmit, watch, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function resendCode() { + setError(""); + setLoading(true); + + const response = await resetPassword({ + loginName, + organization, + requestId, + }) + .catch(() => { + setError("Could not reset password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response) { + setError(response.error); + return; + } + } + + async function submitPassword(values: Inputs) { + setLoading(true); + let payload: { userId: string; password: string; code?: string } = { + userId: userId, + password: values.password, + }; + + // this is not required for initial password setup + if (codeRequired) { + payload = { ...payload, code: values.code }; + } + + const changeResponse = await changePassword(payload) + .catch(() => { + setError("Could not set password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (changeResponse && "error" in changeResponse) { + setError(changeResponse.error); + return; + } + + if (!changeResponse) { + setError("Could not set password"); + return; + } + + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (organization) { + params.append("organization", organization); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait for a second to avoid eventual consistency issues with an initial password being set + + const passwordResponse = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if ( + passwordResponse && + "error" in passwordResponse && + passwordResponse.error + ) { + setError(passwordResponse.error); + return; + } + + if ( + passwordResponse && + "redirect" in passwordResponse && + passwordResponse.redirect + ) { + return router.push(passwordResponse.redirect); + } + + return; + } + + const { errors } = formState; + + const watchPassword = watch("password", ""); + const watchConfirmPassword = watch("confirmPassword", ""); + + const hasMinLength = + passwordComplexitySettings && + watchPassword?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(watchPassword); + const hasNumber = numberValidator(watchPassword); + const hasUppercase = upperCaseValidator(watchPassword); + const hasLowercase = lowerCaseValidator(watchPassword); + + const policyIsValid = + passwordComplexitySettings && + (passwordComplexitySettings.requiresLowercase ? hasLowercase : true) && + (passwordComplexitySettings.requiresNumber ? hasNumber : true) && + (passwordComplexitySettings.requiresUppercase ? hasUppercase : true) && + (passwordComplexitySettings.requiresSymbol ? hasSymbol : true) && + hasMinLength; + + return ( +
+
+ {codeRequired && ( + +
+ + {t("set.noCodeReceived")} + + +
+
+ )} + {codeRequired && ( +
+ +
+ )} +
+ +
+
+ +
+
+ + {passwordComplexitySettings && ( + + )} + + {error && {error}} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/set-register-password-form.tsx b/login/apps/login/src/components/set-register-password-form.tsx new file mode 100644 index 0000000000..3e48c649c1 --- /dev/null +++ b/login/apps/login/src/components/set-register-password-form.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { + lowerCaseValidator, + numberValidator, + symbolValidator, + upperCaseValidator, +} from "@/helpers/validators"; +import { registerUser } from "@/lib/server/register"; +import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FieldValues, useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { PasswordComplexity } from "./password-complexity"; +import { Spinner } from "./spinner"; + +type Inputs = + | { + password: string; + confirmPassword: string; + } + | FieldValues; + +type Props = { + passwordComplexitySettings: PasswordComplexitySettings; + email: string; + firstname: string; + lastname: string; + organization: string; + requestId?: string; +}; + +export function SetRegisterPasswordForm({ + passwordComplexitySettings, + email, + firstname, + lastname, + organization, + requestId, +}: Props) { + const t = useTranslations("register"); + + const { register, handleSubmit, watch, formState } = useForm({ + mode: "onBlur", + defaultValues: { + email: email ?? "", + firstname: firstname ?? "", + lastname: lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const router = useRouter(); + + async function submitRegister(values: Inputs) { + setLoading(true); + const response = await registerUser({ + email: email, + firstName: firstname, + lastName: lastname, + organization: organization, + requestId: requestId, + password: values.password, + }) + .catch(() => { + setError("Could not register user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + const { errors } = formState; + + const watchPassword = watch("password", ""); + const watchConfirmPassword = watch("confirmPassword", ""); + + const hasMinLength = + passwordComplexitySettings && + watchPassword?.length >= passwordComplexitySettings.minLength; + const hasSymbol = symbolValidator(watchPassword); + const hasNumber = numberValidator(watchPassword); + const hasUppercase = upperCaseValidator(watchPassword); + const hasLowercase = lowerCaseValidator(watchPassword); + + const policyIsValid = + passwordComplexitySettings && + (passwordComplexitySettings.requiresLowercase ? hasLowercase : true) && + (passwordComplexitySettings.requiresNumber ? hasNumber : true) && + (passwordComplexitySettings.requiresUppercase ? hasUppercase : true) && + (passwordComplexitySettings.requiresSymbol ? hasSymbol : true) && + hasMinLength; + + return ( +
+
+
+ +
+
+ +
+
+ + {passwordComplexitySettings && ( + + )} + + {error && {error}} + +
+ + +
+ + ); +} diff --git a/login/apps/login/src/components/sign-in-with-idp.tsx b/login/apps/login/src/components/sign-in-with-idp.tsx new file mode 100644 index 0000000000..9c1d339ffa --- /dev/null +++ b/login/apps/login/src/components/sign-in-with-idp.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { idpTypeToSlug } from "@/lib/idp"; +import { redirectToIdp } from "@/lib/server/idp"; +import { + IdentityProvider, + IdentityProviderType, +} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { ReactNode, useActionState } from "react"; +import { Alert } from "./alert"; +import { SignInWithIdentityProviderProps } from "./idps/base-button"; +import { SignInWithApple } from "./idps/sign-in-with-apple"; +import { SignInWithAzureAd } from "./idps/sign-in-with-azure-ad"; +import { SignInWithGeneric } from "./idps/sign-in-with-generic"; +import { SignInWithGithub } from "./idps/sign-in-with-github"; +import { SignInWithGitlab } from "./idps/sign-in-with-gitlab"; +import { SignInWithGoogle } from "./idps/sign-in-with-google"; + +export interface SignInWithIDPProps { + children?: ReactNode; + identityProviders: IdentityProvider[]; + requestId?: string; + organization?: string; + linkOnly?: boolean; +} + +export function SignInWithIdp({ + identityProviders, + requestId, + organization, + linkOnly, +}: Readonly) { + const [state, action, _isPending] = useActionState(redirectToIdp, {}); + + const renderIDPButton = (idp: IdentityProvider, index: number) => { + const { id, name, type } = idp; + + const components: Partial< + Record< + IdentityProviderType, + (props: SignInWithIdentityProviderProps) => ReactNode + > + > = { + [IdentityProviderType.APPLE]: SignInWithApple, + [IdentityProviderType.OAUTH]: SignInWithGeneric, + [IdentityProviderType.OIDC]: SignInWithGeneric, + [IdentityProviderType.GITHUB]: SignInWithGithub, + [IdentityProviderType.GITHUB_ES]: SignInWithGithub, + [IdentityProviderType.AZURE_AD]: SignInWithAzureAd, + [IdentityProviderType.GOOGLE]: (props) => ( + + ), + [IdentityProviderType.GITLAB]: SignInWithGitlab, + [IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab, + [IdentityProviderType.SAML]: SignInWithGeneric, + [IdentityProviderType.JWT]: SignInWithGeneric, + }; + + const Component = components[type]; + return Component ? ( +
+ + + + + + + + ) : null; + }; + + return ( +
+ {!!identityProviders.length && identityProviders?.map(renderIDPButton)} + {state?.error && ( +
+ {state?.error} +
+ )} +
+ ); +} + +SignInWithIdp.displayName = "SignInWithIDP"; diff --git a/login/apps/login/src/components/skeleton-card.tsx b/login/apps/login/src/components/skeleton-card.tsx new file mode 100644 index 0000000000..80b3793e8f --- /dev/null +++ b/login/apps/login/src/components/skeleton-card.tsx @@ -0,0 +1,16 @@ +import { clsx } from "clsx"; + +export const SkeletonCard = ({ isLoading }: { isLoading?: boolean }) => ( +
+
+
+
+
+
+
+); diff --git a/login/apps/login/src/components/skeleton.tsx b/login/apps/login/src/components/skeleton.tsx new file mode 100644 index 0000000000..548953d278 --- /dev/null +++ b/login/apps/login/src/components/skeleton.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react"; + +export function Skeleton({ children }: { children?: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/login/apps/login/src/components/spinner.tsx b/login/apps/login/src/components/spinner.tsx new file mode 100644 index 0000000000..5ed2f04c80 --- /dev/null +++ b/login/apps/login/src/components/spinner.tsx @@ -0,0 +1,22 @@ +import { FC } from "react"; + +export const Spinner: FC<{ className?: string }> = ({ className = "" }) => { + return ( + + + + + ); +}; diff --git a/login/apps/login/src/components/state-badge.tsx b/login/apps/login/src/components/state-badge.tsx new file mode 100644 index 0000000000..00151390bf --- /dev/null +++ b/login/apps/login/src/components/state-badge.tsx @@ -0,0 +1,40 @@ +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +export enum BadgeState { + Info = "info", + Error = "error", + Success = "success", + Alert = "alert", +} + +export type StateBadgeProps = { + state: BadgeState; + children: ReactNode; + evenPadding?: boolean; +}; + +const getBadgeClasses = (state: BadgeState, evenPadding: boolean) => + clsx({ + "w-fit border-box h-18.5px flex flex-row items-center whitespace-nowrap tracking-wider leading-4 items-center justify-center px-2 py-2px text-12px rounded-full shadow-sm": + true, + "bg-state-success-light-background text-state-success-light-color dark:bg-state-success-dark-background dark:text-state-success-dark-color ": + state === BadgeState.Success, + "bg-state-neutral-light-background text-state-neutral-light-color dark:bg-state-neutral-dark-background dark:text-state-neutral-dark-color": + state === BadgeState.Info, + "bg-state-error-light-background text-state-error-light-color dark:bg-state-error-dark-background dark:text-state-error-dark-color": + state === BadgeState.Error, + "bg-state-alert-light-background text-state-alert-light-color dark:bg-state-alert-dark-background dark:text-state-alert-dark-color": + state === BadgeState.Alert, + "p-[2px]": evenPadding, + }); + +export function StateBadge({ + state = BadgeState.Success, + evenPadding = false, + children, +}: StateBadgeProps) { + return ( + {children} + ); +} diff --git a/login/apps/login/src/components/tab-group.tsx b/login/apps/login/src/components/tab-group.tsx new file mode 100644 index 0000000000..afa625d345 --- /dev/null +++ b/login/apps/login/src/components/tab-group.tsx @@ -0,0 +1,16 @@ +import { Tab } from "@/components/tab"; + +export type Item = { + text: string; + slug?: string; +}; + +export const TabGroup = ({ path, items }: { path: string; items: Item[] }) => { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; diff --git a/login/apps/login/src/components/tab.tsx b/login/apps/login/src/components/tab.tsx new file mode 100644 index 0000000000..bd82931b5a --- /dev/null +++ b/login/apps/login/src/components/tab.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { Item } from "@/components/tab-group"; +import { clsx } from "clsx"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; + +export const Tab = ({ + path, + item: { slug, text }, +}: { + path: string; + item: Item; +}) => { + const segment = useSelectedLayoutSegment(); + const href = slug ? path + "/" + slug : path; + const isActive = + // Example home pages e.g. `/layouts` + (!slug && segment === null) || + // Nested pages e.g. `/layouts/electronics` + segment === slug; + + return ( + + {text} + + ); +}; diff --git a/login/apps/login/src/components/theme-provider.tsx b/login/apps/login/src/components/theme-provider.tsx new file mode 100644 index 0000000000..a8a72f86a6 --- /dev/null +++ b/login/apps/login/src/components/theme-provider.tsx @@ -0,0 +1,16 @@ +"use client"; +import { ThemeProvider as ThemeP } from "next-themes"; +import { ReactNode } from "react"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/login/apps/login/src/components/theme-wrapper.tsx b/login/apps/login/src/components/theme-wrapper.tsx new file mode 100644 index 0000000000..314c3a2ef0 --- /dev/null +++ b/login/apps/login/src/components/theme-wrapper.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { setTheme } from "@/helpers/colors"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { ReactNode, useEffect } from "react"; + +type Props = { + branding: BrandingSettings | undefined; + children: ReactNode; +}; + +export const ThemeWrapper = ({ children, branding }: Props) => { + useEffect(() => { + setTheme(document, branding); + }, [branding]); + + return
{children}
; +}; diff --git a/login/apps/login/src/components/theme.tsx b/login/apps/login/src/components/theme.tsx new file mode 100644 index 0000000000..86d39476ff --- /dev/null +++ b/login/apps/login/src/components/theme.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export function Theme() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + const isDark = resolvedTheme === "dark"; + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/login/apps/login/src/components/totp-register.tsx b/login/apps/login/src/components/totp-register.tsx new file mode 100644 index 0000000000..b5c81d8645 --- /dev/null +++ b/login/apps/login/src/components/totp-register.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { getNextUrl } from "@/lib/client"; +import { verifyTOTP } from "@/lib/server/verify"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { QRCodeSVG } from "qrcode.react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { Button, ButtonVariants } from "./button"; +import { CopyToClipboard } from "./copy-to-clipboard"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + code: string; +}; + +type Props = { + uri: string; + secret: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + checkAfter?: boolean; + loginSettings?: LoginSettings; +}; +export function TotpRegister({ + uri, + secret, + loginName, + sessionId, + requestId, + organization, + checkAfter, + loginSettings, +}: Props) { + const t = useTranslations("otp"); + + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: "", + }, + }); + + async function continueWithCode(values: Inputs) { + setLoading(true); + return verifyTOTP(values.code, loginName, organization) + .then(async () => { + // if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login) + if (checkAfter) { + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + + return router.push(`/otp/time-based?` + params); + } else { + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + if (url) { + return router.push(url); + } + } + }) + .catch((e) => { + setError(e.message); + return; + }) + .finally(() => { + setLoading(false); + }); + } + + return ( +
+ {uri && ( + <> + +
+ + {uri} + + + +
+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + )} +
+ ); +} diff --git a/login/apps/login/src/components/user-avatar.tsx b/login/apps/login/src/components/user-avatar.tsx new file mode 100644 index 0000000000..f2aa0bfed7 --- /dev/null +++ b/login/apps/login/src/components/user-avatar.tsx @@ -0,0 +1,59 @@ +import { Avatar } from "@/components/avatar"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; + +type Props = { + loginName?: string; + displayName?: string; + showDropdown: boolean; + searchParams?: Record; +}; + +export function UserAvatar({ + loginName, + displayName, + showDropdown, + searchParams, +}: Props) { + const params = new URLSearchParams({}); + + if (searchParams?.sessionId) { + params.set("sessionId", searchParams.sessionId); + } + + if (searchParams?.organization) { + params.set("organization", searchParams.organization); + } + + if (searchParams?.requestId) { + params.set("requestId", searchParams.requestId); + } + + if (searchParams?.loginName) { + params.set("loginName", searchParams.loginName); + } + + return ( +
+
+ +
+ + {loginName} + + + {showDropdown && ( + + + + )} +
+ ); +} diff --git a/login/apps/login/src/components/username-form.tsx b/login/apps/login/src/components/username-form.tsx new file mode 100644 index 0000000000..6801f6b274 --- /dev/null +++ b/login/apps/login/src/components/username-form.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { sendLoginname } from "@/lib/server/loginname"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { ReactNode, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + loginName: string; +}; + +type Props = { + loginName: string | undefined; + requestId: string | undefined; + loginSettings: LoginSettings | undefined; + organization?: string; + suffix?: string; + submit: boolean; + allowRegister: boolean; + children?: ReactNode; +}; + +export function UsernameForm({ + loginName, + requestId, + organization, + suffix, + loginSettings, + submit, + allowRegister, + children, +}: Props) { + const t = useTranslations("loginname"); + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + loginName: loginName ? loginName : "", + }, + }); + + const router = useRouter(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitLoginName(values: Inputs, organization?: string) { + setLoading(true); + + const res = await sendLoginname({ + loginName: values.loginName, + organization, + requestId, + suffix, + }) + .catch(() => { + setError("An internal error occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (res && "redirect" in res && res.redirect) { + return router.push(res.redirect); + } + + if (res && "error" in res && res.error) { + setError(res.error); + return; + } + + return res; + } + + useEffect(() => { + if (submit && loginName) { + // When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid. + submitLoginName({ loginName }, organization); + } + }, []); + + let inputLabel = "Loginname"; + if ( + loginSettings?.disableLoginWithEmail && + loginSettings?.disableLoginWithPhone + ) { + inputLabel = "Username"; + } else if (loginSettings?.disableLoginWithEmail) { + inputLabel = "Username or phone number"; + } else if (loginSettings?.disableLoginWithPhone) { + inputLabel = "Username or email"; + } + + return ( +
+
+ + {allowRegister && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + +
{children}
+ +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/verify-form.tsx b/login/apps/login/src/components/verify-form.tsx new file mode 100644 index 0000000000..0933f598dd --- /dev/null +++ b/login/apps/login/src/components/verify-form.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { Alert, AlertType } from "@/components/alert"; +import { resendVerification, sendVerification } from "@/lib/server/verify"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + code: string; +}; + +type Props = { + userId: string; + loginName?: string; + organization?: string; + code?: string; + isInvite: boolean; + requestId?: string; +}; + +export function VerifyForm({ + userId, + loginName, + organization, + requestId, + code, + isInvite, +}: Props) { + const t = useTranslations("verify"); + + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ?? "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function resendCode() { + setError(""); + setLoading(true); + + const response = await resendVerification({ + userId, + isInvite: isInvite, + }) + .catch(() => { + setError("Could not resend email"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + return response; + } + + const fcn = useCallback( + async function submitCodeAndContinue( + value: Inputs, + ): Promise { + setLoading(true); + + const response = await sendVerification({ + code: value.code, + userId, + isInvite: isInvite, + loginName: loginName, + organization: organization, + requestId: requestId, + }) + .catch(() => { + setError("Could not verify user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response?.redirect) { + return router.push(response?.redirect); + } + }, + [isInvite, userId], + ); + + useEffect(() => { + if (code) { + fcn({ code }); + } + }, [code, fcn]); + + return ( + <> +
+ +
+ + {t("verify.noCodeReceived")} + + +
+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/login/apps/login/src/components/zitadel-logo-dark.tsx b/login/apps/login/src/components/zitadel-logo-dark.tsx new file mode 100644 index 0000000000..0df6ae2004 --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo-dark.tsx @@ -0,0 +1,210 @@ +import { FC } from "react"; + +export const ZitadelLogoDark: FC = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/login/apps/login/src/components/zitadel-logo-light.tsx b/login/apps/login/src/components/zitadel-logo-light.tsx new file mode 100644 index 0000000000..51529aa821 --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo-light.tsx @@ -0,0 +1,210 @@ +import { FC } from "react"; + +export const ZitadelLogoLight: FC = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/login/apps/login/src/components/zitadel-logo.tsx b/login/apps/login/src/components/zitadel-logo.tsx new file mode 100644 index 0000000000..105665fbba --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo.tsx @@ -0,0 +1,32 @@ +import Image from "next/image"; +type Props = { + height?: number; + width?: number; +}; + +export function ZitadelLogo({ height = 40, width = 147.5 }: Props) { + return ( + <> +
+ {/* */} + + zitadel logo +
+
+ zitadel logo +
+ + ); +} diff --git a/login/apps/login/src/helpers/base64.ts b/login/apps/login/src/helpers/base64.ts new file mode 100644 index 0000000000..967cdc8d17 --- /dev/null +++ b/login/apps/login/src/helpers/base64.ts @@ -0,0 +1,63 @@ +export function coerceToBase64Url(thing: any, name: string) { + // Array or ArrayBuffer to Uint8Array + if (Array.isArray(thing)) { + thing = Uint8Array.from(thing); + } + + if (thing instanceof ArrayBuffer) { + thing = new Uint8Array(thing); + } + + // Uint8Array to base64 + if (thing instanceof Uint8Array) { + var str = ""; + var len = thing.byteLength; + + for (var i = 0; i < len; i++) { + str += String.fromCharCode(thing[i]); + } + thing = window.btoa(str); + } + + if (typeof thing !== "string") { + throw new Error("could not coerce '" + name + "' to string"); + } + + // base64 to base64url + // NOTE: "=" at the end of challenge is optional, strip it off here + thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + + return thing; +} + +export function coerceToArrayBuffer(thing: any, name: string) { + if (typeof thing === "string") { + // base64url to base64 + thing = thing.replace(/-/g, "+").replace(/_/g, "/"); + + // base64 to Uint8Array + var str = window.atob(thing); + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i); + } + thing = bytes; + } + + // Array to Uint8Array + if (Array.isArray(thing)) { + thing = new Uint8Array(thing); + } + + // Uint8Array to ArrayBuffer + if (thing instanceof Uint8Array) { + thing = thing.buffer; + } + + // error if none of the above worked + if (!(thing instanceof ArrayBuffer)) { + throw new TypeError("could not coerce '" + name + "' to ArrayBuffer"); + } + + return thing; +} diff --git a/login/apps/login/src/helpers/colors.ts b/login/apps/login/src/helpers/colors.ts new file mode 100644 index 0000000000..bdb07cecfd --- /dev/null +++ b/login/apps/login/src/helpers/colors.ts @@ -0,0 +1,439 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import tinycolor from "tinycolor2"; + +export interface Color { + name: string; + hex: string; + rgb: string; + contrastColor: string; +} + +export type MapName = "background" | "primary" | "warn" | "text" | "link"; + +export type ColorName = + | "50" + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "C900" + | "A100" + | "A200" + | "A400" + | "A700"; + +export type ColorMap = { + [key in MapName]: Color[]; +}; + +export const DARK_PRIMARY = "#2073c4"; +export const PRIMARY = "#5469d4"; + +export const DARK_WARN = "#ff3b5b"; +export const WARN = "#cd3d56"; + +export const DARK_BACKGROUND = "#111827"; +export const BACKGROUND = "#fafafa"; + +export const DARK_TEXT = "#ffffff"; +export const TEXT = "#000000"; + +export type LabelPolicyColors = { + backgroundColor: string; + backgroundColorDark: string; + fontColor: string; + fontColorDark: string; + warnColor: string; + warnColorDark: string; + primaryColor: string; + primaryColorDark: string; +}; + +type BrandingColors = { + lightTheme: { + backgroundColor: string; + fontColor: string; + primaryColor: string; + warnColor: string; + }; + darkTheme: { + backgroundColor: string; + fontColor: string; + primaryColor: string; + warnColor: string; + }; +}; + +export function setTheme(document: any, policy?: BrandingSettings) { + const lP: BrandingColors = { + lightTheme: { + backgroundColor: policy?.lightTheme?.backgroundColor || BACKGROUND, + fontColor: policy?.lightTheme?.fontColor || TEXT, + primaryColor: policy?.lightTheme?.primaryColor || PRIMARY, + warnColor: policy?.lightTheme?.warnColor || WARN, + }, + darkTheme: { + backgroundColor: policy?.darkTheme?.backgroundColor || DARK_BACKGROUND, + fontColor: policy?.darkTheme?.fontColor || DARK_TEXT, + primaryColor: policy?.darkTheme?.primaryColor || DARK_PRIMARY, + warnColor: policy?.darkTheme?.warnColor || DARK_WARN, + }, + }; + + const dark = computeMap(lP, true); + const light = computeMap(lP, false); + + setColorShades(dark.background, "background", "dark", document); + setColorShades(light.background, "background", "light", document); + + setColorShades(dark.primary, "primary", "dark", document); + setColorShades(light.primary, "primary", "light", document); + + setColorShades(dark.warn, "warn", "dark", document); + setColorShades(light.warn, "warn", "light", document); + + setColorAlpha(dark.text, "text", "dark", document); + setColorAlpha(light.text, "text", "light", document); + + setColorAlpha(dark.link, "link", "dark", document); + setColorAlpha(light.link, "link", "light", document); +} + +function setColorShades( + map: Color[], + type: string, + theme: string, + document: any, +) { + map.forEach((color) => { + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-${color.name}`, + color.hex, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-contrast-${color.name}`, + color.contrastColor, + ); + }); +} + +function setColorAlpha( + map: Color[], + type: string, + theme: string, + document: any, +) { + map.forEach((color) => { + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-${color.name}`, + color.hex, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-contrast-${color.name}`, + color.contrastColor, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-secondary-${color.name}`, + `${color.hex}c7`, + ); + }); +} + +function computeColors(hex: string): Color[] { + return [ + getColorObject(tinycolor(hex).lighten(52), "50"), + getColorObject(tinycolor(hex).lighten(37), "100"), + getColorObject(tinycolor(hex).lighten(26), "200"), + getColorObject(tinycolor(hex).lighten(12), "300"), + getColorObject(tinycolor(hex).lighten(6), "400"), + getColorObject(tinycolor(hex), "500"), + getColorObject(tinycolor(hex).darken(6), "600"), + getColorObject(tinycolor(hex).darken(12), "700"), + getColorObject(tinycolor(hex).darken(18), "800"), + getColorObject(tinycolor(hex).darken(24), "900"), + getColorObject(tinycolor(hex).lighten(50).saturate(30), "A100"), + getColorObject(tinycolor(hex).lighten(30).saturate(30), "A200"), + getColorObject(tinycolor(hex).lighten(10).saturate(15), "A400"), + getColorObject(tinycolor(hex).lighten(5).saturate(5), "A700"), + ]; +} + +function getColorObject(value: any, name: string): Color { + const c = tinycolor(value); + return { + name: name, + hex: c.toHexString(), + rgb: c.toRgbString(), + contrastColor: getContrast(c.toHexString()), + } as Color; +} + +function getContrast(color: string): string { + const onBlack = tinycolor.readability("#000", color); + const onWhite = tinycolor.readability("#fff", color); + if (onBlack > onWhite) { + return "hsla(0, 0%, 0%, 0.87)"; + } else { + return "#ffffff"; + } +} + +export function computeMap(branding: BrandingColors, dark: boolean): ColorMap { + return { + background: computeColors( + dark + ? branding.darkTheme.backgroundColor + : branding.lightTheme.backgroundColor, + ), + primary: computeColors( + dark ? branding.darkTheme.primaryColor : branding.lightTheme.primaryColor, + ), + warn: computeColors( + dark ? branding.darkTheme.warnColor : branding.lightTheme.warnColor, + ), + text: computeColors( + dark ? branding.darkTheme.fontColor : branding.lightTheme.fontColor, + ), + link: computeColors( + dark ? branding.darkTheme.fontColor : branding.lightTheme.fontColor, + ), + }; +} + +export interface ColorShade { + 200: string; + 300: string; + 500: string; + 600: string; + 700: string; + 900: string; +} + +export const COLORS = [ + { + 500: "#ef4444", + 200: "#fecaca", + 300: "#fca5a5", + 600: "#dc2626", + 700: "#b91c1c", + 900: "#7f1d1d", + }, + { + 500: "#f97316", + 200: "#fed7aa", + 300: "#fdba74", + 600: "#ea580c", + 700: "#c2410c", + 900: "#7c2d12", + }, + { + 500: "#f59e0b", + 200: "#fde68a", + 300: "#fcd34d", + 600: "#d97706", + 700: "#b45309", + 900: "#78350f", + }, + { + 500: "#eab308", + 200: "#fef08a", + 300: "#fde047", + 600: "#ca8a04", + 700: "#a16207", + 900: "#713f12", + }, + { + 500: "#84cc16", + 200: "#d9f99d", + 300: "#bef264", + 600: "#65a30d", + 700: "#4d7c0f", + 900: "#365314", + }, + { + 500: "#22c55e", + 200: "#bbf7d0", + 300: "#86efac", + 600: "#16a34a", + 700: "#15803d", + 900: "#14532d", + }, + { + 500: "#10b981", + 200: "#a7f3d0", + 300: "#6ee7b7", + 600: "#059669", + 700: "#047857", + 900: "#064e3b", + }, + { + 500: "#14b8a6", + 200: "#99f6e4", + 300: "#5eead4", + 600: "#0d9488", + 700: "#0f766e", + 900: "#134e4a", + }, + { + 500: "#06b6d4", + 200: "#a5f3fc", + 300: "#67e8f9", + 600: "#0891b2", + 700: "#0e7490", + 900: "#164e63", + }, + { + 500: "#0ea5e9", + 200: "#bae6fd", + 300: "#7dd3fc", + 600: "#0284c7", + 700: "#0369a1", + 900: "#0c4a6e", + }, + { + 500: "#3b82f6", + 200: "#bfdbfe", + 300: "#93c5fd", + 600: "#2563eb", + 700: "#1d4ed8", + 900: "#1e3a8a", + }, + { + 500: "#6366f1", + 200: "#c7d2fe", + 300: "#a5b4fc", + 600: "#4f46e5", + 700: "#4338ca", + 900: "#312e81", + }, + { + 500: "#8b5cf6", + 200: "#ddd6fe", + 300: "#c4b5fd", + 600: "#7c3aed", + 700: "#6d28d9", + 900: "#4c1d95", + }, + { + 500: "#a855f7", + 200: "#e9d5ff", + 300: "#d8b4fe", + 600: "#9333ea", + 700: "#7e22ce", + 900: "#581c87", + }, + { + 500: "#d946ef", + 200: "#f5d0fe", + 300: "#f0abfc", + 600: "#c026d3", + 700: "#a21caf", + 900: "#701a75", + }, + { + 500: "#ec4899", + 200: "#fbcfe8", + 300: "#f9a8d4", + 600: "#db2777", + 700: "#be185d", + 900: "#831843", + }, + { + 500: "#f43f5e", + 200: "#fecdd3", + 300: "#fda4af", + 600: "#e11d48", + 700: "#be123c", + 900: "#881337", + }, +]; + +export function getColorHash(value: string): ColorShade { + let hash = 0; + + if (value.length === 0) { + return COLORS[hash]; + } + + hash = hashCode(value); + return COLORS[hash % COLORS.length]; +} + +export function hashCode(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +export function getMembershipColor(role: string): ColorShade { + const hash = hashCode(role); + let color = COLORS[hash % COLORS.length]; + + switch (role) { + case "IAM_OWNER": + color = COLORS[0]; + break; + case "IAM_OWNER_VIEWER": + color = COLORS[14]; + break; + case "IAM_ORG_MANAGER": + color = COLORS[11]; + break; + case "IAM_USER_MANAGER": + color = COLORS[8]; + break; + + case "ORG_OWNER": + color = COLORS[16]; + break; + case "ORG_USER_MANAGER": + color = COLORS[8]; + break; + case "ORG_OWNER_VIEWER": + color = COLORS[14]; + break; + case "ORG_USER_PERMISSION_EDITOR": + color = COLORS[7]; + break; + case "ORG_PROJECT_PERMISSION_EDITOR": + color = COLORS[11]; + break; + case "ORG_PROJECT_CREATOR": + color = COLORS[12]; + break; + + case "PROJECT_OWNER": + color = COLORS[9]; + break; + case "PROJECT_OWNER_VIEWER": + color = COLORS[10]; + break; + case "PROJECT_OWNER_GLOBAL": + color = COLORS[11]; + break; + case "PROJECT_OWNER_VIEWER_GLOBAL": + color = COLORS[12]; + break; + + default: + color = COLORS[hash % COLORS.length]; + break; + } + + return color; +} diff --git a/login/apps/login/src/helpers/validators.ts b/login/apps/login/src/helpers/validators.ts new file mode 100644 index 0000000000..6a61d13ece --- /dev/null +++ b/login/apps/login/src/helpers/validators.ts @@ -0,0 +1,19 @@ +export function symbolValidator(value: string): boolean { + const REGEXP = /[^a-zA-Z0-9]/gi; + return REGEXP.test(value); +} + +export function numberValidator(value: string): boolean { + const REGEXP = /[0-9]/g; + return REGEXP.test(value); +} + +export function upperCaseValidator(value: string): boolean { + const REGEXP = /[A-Z]/g; + return REGEXP.test(value); +} + +export function lowerCaseValidator(value: string): boolean { + const REGEXP = /[a-z]/g; + return REGEXP.test(value); +} diff --git a/login/apps/login/src/i18n/request.ts b/login/apps/login/src/i18n/request.ts new file mode 100644 index 0000000000..59c9da42cc --- /dev/null +++ b/login/apps/login/src/i18n/request.ts @@ -0,0 +1,35 @@ +import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; +import deepmerge from "deepmerge"; +import { getRequestConfig } from "next-intl/server"; +import { cookies, headers } from "next/headers"; + +export default getRequestConfig(async () => { + const fallback = "en"; + const cookiesList = await cookies(); + + let locale: string = fallback; + + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); + if (languageHeader) { + const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code + if (LANGS.map((l) => l.code).includes(headerLocale)) { + locale = headerLocale; + } + } + + const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); + if (languageCookie && languageCookie.value) { + if (LANGS.map((l) => l.code).includes(languageCookie.value)) { + locale = languageCookie.value; + } + } + + const userMessages = (await import(`../../locales/${locale}.json`)).default; + const fallbackMessages = (await import(`../../locales/${fallback}.json`)) + .default; + + return { + locale, + messages: deepmerge(fallbackMessages, userMessages), + }; +}); diff --git a/login/apps/login/src/lib/api.ts b/login/apps/login/src/lib/api.ts new file mode 100644 index 0000000000..7324007307 --- /dev/null +++ b/login/apps/login/src/lib/api.ts @@ -0,0 +1,17 @@ +import { newSystemToken } from "@zitadel/client/node"; + +export async function systemAPIToken() { + const token = { + audience: process.env.AUDIENCE, + userID: process.env.SYSTEM_USER_ID, + token: Buffer.from(process.env.SYSTEM_USER_PRIVATE_KEY, "base64").toString( + "utf-8", + ), + }; + + return newSystemToken({ + audience: token.audience, + subject: token.userID, + key: token.token, + }); +} diff --git a/login/apps/login/src/lib/client.ts b/login/apps/login/src/lib/client.ts new file mode 100644 index 0000000000..a59af90b77 --- /dev/null +++ b/login/apps/login/src/lib/client.ts @@ -0,0 +1,80 @@ +type FinishFlowCommand = + | { + sessionId: string; + requestId: string; + } + | { loginName: string }; + +function goToSignedInPage( + props: + | { sessionId: string; organization?: string; requestId?: string } + | { organization?: string; loginName: string; requestId?: string }, +) { + const params = new URLSearchParams({}); + + if ("loginName" in props && props.loginName) { + params.append("loginName", props.loginName); + } + + if ("sessionId" in props && props.sessionId) { + params.append("sessionId", props.sessionId); + } + + if (props.organization) { + params.append("organization", props.organization); + } + + // required to show conditional UI for device flow + if (props.requestId) { + params.append("requestId", props.requestId); + } + + return `/signedin?` + params; +} + +/** + * for client: redirects user back to an OIDC or SAML application or to a success page when using requestId, check if a default redirect and redirect to it, or just redirect to a success page with the loginName + * @param command + * @returns + */ +export async function getNextUrl( + command: FinishFlowCommand & { organization?: string }, + defaultRedirectUri?: string, +): Promise { + // finish Device Authorization Flow + if ( + "requestId" in command && + command.requestId.startsWith("device_") && + ("loginName" in command || "sessionId" in command) + ) { + return goToSignedInPage({ + ...command, + organization: command.organization, + }); + } + + // finish SAML or OIDC flow + if ( + "sessionId" in command && + "requestId" in command && + (command.requestId.startsWith("saml_") || + command.requestId.startsWith("oidc_")) + ) { + const params = new URLSearchParams({ + sessionId: command.sessionId, + requestId: command.requestId, + }); + + if (command.organization) { + params.append("organization", command.organization); + } + + return `/login?` + params; + } + + if (defaultRedirectUri) { + return defaultRedirectUri; + } + + return goToSignedInPage(command); +} diff --git a/login/apps/login/src/lib/cookies.ts b/login/apps/login/src/lib/cookies.ts new file mode 100644 index 0000000000..7de87a98e7 --- /dev/null +++ b/login/apps/login/src/lib/cookies.ts @@ -0,0 +1,341 @@ +"use server"; + +import { timestampDate, timestampFromMs } from "@zitadel/client"; +import { cookies } from "next/headers"; +import { LANGUAGE_COOKIE_NAME } from "./i18n"; + +// TODO: improve this to handle overflow +const MAX_COOKIE_SIZE = 2048; + +export type Cookie = { + id: string; + token: string; + loginName: string; + organization?: string; + creationTs: string; + expirationTs: string; + changeTs: string; + requestId?: string; // if its linked to an OIDC flow +}; + +type SessionCookie = Cookie & T; + +async function setSessionHttpOnlyCookie( + sessions: SessionCookie[], + sameSite: boolean | "lax" | "strict" | "none" = true, +) { + const cookiesList = await cookies(); + + return cookiesList.set({ + name: "sessions", + value: JSON.stringify(sessions), + httpOnly: true, + path: "/", + sameSite: process.env.NODE_ENV === "production" ? sameSite : "lax", + secure: process.env.NODE_ENV === "production", + }); +} + +export async function setLanguageCookie(language: string) { + const cookiesList = await cookies(); + + await cookiesList.set({ + name: LANGUAGE_COOKIE_NAME, + value: language, + httpOnly: true, + path: "/", + }); +} + +export async function addSessionToCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + let currentSessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : []; + + const index = currentSessions.findIndex( + (s) => s.loginName === session.loginName, + ); + + if (index > -1) { + currentSessions[index] = session; + } else { + const temp = [...currentSessions, session]; + + if (JSON.stringify(temp).length >= MAX_COOKIE_SIZE) { + console.log("WARNING COOKIE OVERFLOW"); + // TODO: improve cookie handling + // this replaces the first session (oldest) with the new one + currentSessions = [session].concat(currentSessions.slice(1)); + } else { + currentSessions = [session].concat(currentSessions); + } + } + + if (cleanup) { + const now = new Date(); + const filteredSessions = currentSessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(currentSessions, sameSite); + } +} + +export async function updateSessionCookie({ + id, + session, + cleanup, + sameSite, +}: { + id: string; + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + const sessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : [session]; + + const foundIndex = sessions.findIndex((session) => session.id === id); + + if (foundIndex > -1) { + sessions[foundIndex] = session; + if (cleanup) { + const now = new Date(); + const filteredSessions = sessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(sessions, sameSite); + } + } else { + throw "updateSessionCookie: session id now found"; + } +} + +export async function removeSessionFromCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}) { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + const sessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : [session]; + + const reducedSessions = sessions.filter((s) => s.id !== session.id); + if (cleanup) { + const now = new Date(); + const filteredSessions = reducedSessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(reducedSessions, sameSite); + } +} + +export async function getMostRecentSessionCookie(): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const latest = sessions.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }); + + return latest; + } else { + return Promise.reject("no session cookie found"); + } +} + +export async function getSessionCookieById({ + sessionId, + organization, +}: { + sessionId: string; + organization?: string; +}): Promise> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const found = sessions.find((s) => + organization + ? s.organization === organization && s.id === sessionId + : s.id === sessionId, + ); + if (found) { + return found; + } else { + return Promise.reject(); + } + } else { + return Promise.reject(); + } +} + +export async function getSessionCookieByLoginName({ + loginName, + organization, +}: { + loginName?: string; + organization?: string; +}): Promise> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + const found = sessions.find((s) => + organization + ? s.organization === organization && s.loginName === loginName + : s.loginName === loginName, + ); + if (found) { + return found; + } else { + return Promise.reject("no cookie found with loginName: " + loginName); + } + } else { + return Promise.reject("no session cookie found"); + } +} + +/** + * + * @param cleanup when true, removes all expired sessions, default true + * @returns Session Cookies + */ +export async function getAllSessionCookieIds( + cleanup: boolean = false, +): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + if (cleanup) { + const now = new Date(); + return sessions + .filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ) + .map((session) => session.id); + } else { + return sessions.map((session) => session.id); + } + } else { + return []; + } +} + +/** + * + * @param cleanup when true, removes all expired sessions, default true + * @returns Session Cookies + */ +export async function getAllSessions( + cleanup: boolean = false, +): Promise[]> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + if (cleanup) { + const now = new Date(); + return sessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + } else { + return sessions; + } + } else { + return []; + } +} + +/** + * Returns most recent session filtered by optinal loginName + * @param loginName optional loginName to filter cookies, if non provided, returns most recent session + * @param organization optional organization to filter cookies + * @returns most recent session + */ +export async function getMostRecentCookieWithLoginname({ + loginName, + organization, +}: { + loginName?: string; + organization?: string; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + let filtered = sessions.filter((cookie) => { + return !!loginName ? cookie.loginName === loginName : true; + }); + + if (organization) { + filtered = filtered.filter((cookie) => { + return cookie.organization === organization; + }); + } + + const latest = + filtered && filtered.length + ? filtered.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }) + : undefined; + + if (latest) { + return latest; + } else { + return Promise.reject("Could not get the context or retrieve a session"); + } + } else { + return Promise.reject("Could not read session cookie"); + } +} diff --git a/login/apps/login/src/lib/demos.ts b/login/apps/login/src/lib/demos.ts new file mode 100644 index 0000000000..38912e50e5 --- /dev/null +++ b/login/apps/login/src/lib/demos.ts @@ -0,0 +1,38 @@ +export type Item = { + name: string; + slug: string; + description?: string; +}; + +export const demos: { name: string; items: Item[] }[] = [ + { + name: "Login", + items: [ + { + name: "Loginname", + slug: "loginname", + description: "Start the loginflow with loginname", + }, + { + name: "Accounts", + slug: "accounts", + description: "List active and inactive sessions", + }, + ], + }, + { + name: "Register", + items: [ + { + name: "Register", + slug: "register", + description: "Add a user with password or passkey", + }, + { + name: "IDP Register", + slug: "idp", + description: "Add a user from an external identity provider", + }, + ], + }, +]; diff --git a/login/apps/login/src/lib/fingerprint.ts b/login/apps/login/src/lib/fingerprint.ts new file mode 100644 index 0000000000..55b59dadc8 --- /dev/null +++ b/login/apps/login/src/lib/fingerprint.ts @@ -0,0 +1,66 @@ +import { create } from "@zitadel/client"; +import { + UserAgent, + UserAgentSchema, +} from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { cookies, headers } from "next/headers"; +import { userAgent } from "next/server"; +import { v4 as uuidv4 } from "uuid"; + +export async function getFingerprintId() { + return uuidv4(); +} + +export async function setFingerprintIdCookie(fingerprintId: string) { + const cookiesList = await cookies(); + + return cookiesList.set({ + name: "fingerprintId", + value: fingerprintId, + httpOnly: true, + path: "/", + maxAge: 31536000, // 1 year + }); +} + +export async function getFingerprintIdCookie() { + const cookiesList = await cookies(); + return cookiesList.get("fingerprintId"); +} + +export async function getOrSetFingerprintId(): Promise { + const cookie = await getFingerprintIdCookie(); + if (cookie) { + return cookie.value; + } + + const fingerprintId = await getFingerprintId(); + await setFingerprintIdCookie(fingerprintId); + return fingerprintId; +} + +export async function getUserAgent(): Promise { + const _headers = await headers(); + + const fingerprintId = await getOrSetFingerprintId(); + + const { device, engine, os, browser } = userAgent({ headers: _headers }); + + const userAgentHeader = _headers.get("user-agent"); + + const userAgentHeaderValues = userAgentHeader?.split(","); + + const deviceDescription = `${device?.type ? `${device.type},` : ""} ${device?.vendor ? `${device.vendor},` : ""} ${device.model ? `${device.model},` : ""} `; + const osDescription = `${os?.name ? `${os.name},` : ""} ${os?.version ? `${os.version},` : ""} `; + const engineDescription = `${engine?.name ? `${engine.name},` : ""} ${engine?.version ? `${engine.version},` : ""} `; + const browserDescription = `${browser?.name ? `${browser.name},` : ""} ${browser.version ? `${browser.version},` : ""} `; + + const userAgentData: UserAgent = create(UserAgentSchema, { + ip: _headers.get("x-forwarded-for") ?? _headers.get("remoteAddress") ?? "", + header: { "user-agent": { values: userAgentHeaderValues } }, + description: `${browserDescription}, ${deviceDescription}, ${engineDescription}, ${osDescription}`, + fingerprintId: fingerprintId, + }); + + return userAgentData; +} diff --git a/login/apps/login/src/lib/hooks.ts b/login/apps/login/src/lib/hooks.ts new file mode 100644 index 0000000000..2d43fe5adc --- /dev/null +++ b/login/apps/login/src/lib/hooks.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +// Custom hook to read auth record and user profile doc +export function useUserData() { + const [clientData, setClientData] = useState(null); + + useEffect(() => { + let unsubscribe; + + return unsubscribe; + }, [clientData]); + + return { clientData }; +} diff --git a/login/apps/login/src/lib/i18n.ts b/login/apps/login/src/lib/i18n.ts new file mode 100644 index 0000000000..5a101dcc8f --- /dev/null +++ b/login/apps/login/src/lib/i18n.ts @@ -0,0 +1,38 @@ +export interface Lang { + name: string; + code: string; +} + +export const LANGS: Lang[] = [ + { + name: "English", + code: "en", + }, + { + name: "Deutsch", + code: "de", + }, + { + name: "Italiano", + code: "it", + }, + { + name: "Español", + code: "es", + }, + { + name: "Polski", + code: "pl", + }, + { + name: "简体中文", + code: "zh", + }, + { + name: "Русский", + code: "ru", + }, +]; + +export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE"; +export const LANGUAGE_HEADER_NAME = "accept-language"; diff --git a/login/apps/login/src/lib/idp.ts b/login/apps/login/src/lib/idp.ts new file mode 100644 index 0000000000..a62889efa3 --- /dev/null +++ b/login/apps/login/src/lib/idp.ts @@ -0,0 +1,75 @@ +import { IDPType } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; + +// This maps the IdentityProviderType to a slug which is used in the /success and /failure routes +export function idpTypeToSlug(idpType: IdentityProviderType) { + switch (idpType) { + case IdentityProviderType.GITHUB: + return "github"; + case IdentityProviderType.GITHUB_ES: + return "github_es"; + case IdentityProviderType.GITLAB: + return "gitlab"; + case IdentityProviderType.GITLAB_SELF_HOSTED: + return "gitlab_es"; + case IdentityProviderType.APPLE: + return "apple"; + case IdentityProviderType.GOOGLE: + return "google"; + case IdentityProviderType.AZURE_AD: + return "azure"; + case IdentityProviderType.SAML: + return "saml"; + case IdentityProviderType.OAUTH: + return "oauth"; + case IdentityProviderType.OIDC: + return "oidc"; + case IdentityProviderType.JWT: + return "jwt"; + default: + throw new Error("Unknown identity provider type"); + } +} + +// TODO: this is ugly but needed atm as the getIDPByID returns a IDPType and not a IdentityProviderType +export function idpTypeToIdentityProviderType( + idpType: IDPType, +): IdentityProviderType { + switch (idpType) { + case IDPType.IDP_TYPE_GITHUB: + return IdentityProviderType.GITHUB; + + case IDPType.IDP_TYPE_GITHUB_ES: + return IdentityProviderType.GITHUB_ES; + + case IDPType.IDP_TYPE_GITLAB: + return IdentityProviderType.GITLAB; + + case IDPType.IDP_TYPE_GITLAB_SELF_HOSTED: + return IdentityProviderType.GITLAB_SELF_HOSTED; + + case IDPType.IDP_TYPE_APPLE: + return IdentityProviderType.APPLE; + + case IDPType.IDP_TYPE_GOOGLE: + return IdentityProviderType.GOOGLE; + + case IDPType.IDP_TYPE_AZURE_AD: + return IdentityProviderType.AZURE_AD; + + case IDPType.IDP_TYPE_SAML: + return IdentityProviderType.SAML; + + case IDPType.IDP_TYPE_OAUTH: + return IdentityProviderType.OAUTH; + + case IDPType.IDP_TYPE_OIDC: + return IdentityProviderType.OIDC; + + case IDPType.IDP_TYPE_JWT: + return IdentityProviderType.JWT; + + default: + throw new Error("Unknown identity provider type"); + } +} diff --git a/login/apps/login/src/lib/oidc.ts b/login/apps/login/src/lib/oidc.ts new file mode 100644 index 0000000000..b692300dea --- /dev/null +++ b/login/apps/login/src/lib/oidc.ts @@ -0,0 +1,132 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { createCallback, getLoginSettings } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { + CreateCallbackRequestSchema, + SessionSchema, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { NextRequest, NextResponse } from "next/server"; +import { constructUrl } from "./service-url"; +import { isSessionValid } from "./session"; + +type LoginWithOIDCAndSession = { + serviceUrl: string; + authRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; +export async function loginWithOIDCAndSession({ + serviceUrl, + authRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithOIDCAndSession) { + console.log( + `Login with session: ${sessionId} and authRequest: ${authRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `oidc_${authRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: authRequest, + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + if (callbackUrl) { + return NextResponse.redirect(callbackUrl); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/login/apps/login/src/lib/saml.ts b/login/apps/login/src/lib/saml.ts new file mode 100644 index 0000000000..e85084f022 --- /dev/null +++ b/login/apps/login/src/lib/saml.ts @@ -0,0 +1,130 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { createResponse, getLoginSettings } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { NextRequest, NextResponse } from "next/server"; +import { constructUrl } from "./service-url"; +import { isSessionValid } from "./session"; + +type LoginWithSAMLAndSession = { + serviceUrl: string; + samlRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; + +export async function loginWithSAMLAndSession({ + serviceUrl, + samlRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithSAMLAndSession) { + console.log( + `Login with session: ${sessionId} and samlRequest: ${samlRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `saml_${samlRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { url } = await createResponse({ + serviceUrl, + req: create(CreateResponseRequestSchema, { + samlRequestId: samlRequest, + responseKind: { + case: "session", + value: session, + }, + }), + }); + if (url) { + return NextResponse.redirect(url); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/login/apps/login/src/lib/self.ts b/login/apps/login/src/lib/self.ts new file mode 100644 index 0000000000..7375f4f114 --- /dev/null +++ b/login/apps/login/src/lib/self.ts @@ -0,0 +1,64 @@ +"use server"; + +import { createServerTransport } from "@zitadel/client/node"; +import { createUserServiceClient } from "@zitadel/client/v2"; +import { headers } from "next/headers"; +import { getSessionCookieById } from "./cookies"; +import { getServiceUrlFromHeaders } from "./service-url"; +import { getSession } from "./zitadel"; + +const transport = async (serviceUrl: string, token: string) => { + return createServerTransport(token, { + baseUrl: serviceUrl, + }); +}; + +const myUserService = async (serviceUrl: string, sessionToken: string) => { + const transportPromise = await transport(serviceUrl, sessionToken); + return createUserServiceClient(transportPromise); +}; + +export async function setMyPassword({ + sessionId, + password, +}: { + sessionId: string; + password: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session) { + return { error: "Could not load session" }; + } + + const service = await myUserService(serviceUrl, `${sessionCookie.token}`); + + if (!session?.factors?.user?.id) { + return { error: "No user id found in session" }; + } + + return service + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); +} diff --git a/login/apps/login/src/lib/server/cookie.ts b/login/apps/login/src/lib/server/cookie.ts new file mode 100644 index 0000000000..841fc06b3a --- /dev/null +++ b/login/apps/login/src/lib/server/cookie.ts @@ -0,0 +1,278 @@ +"use server"; + +import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies"; +import { + createSessionForUserIdAndIdpIntent, + createSessionFromChecks, + getSecuritySettings, + getSession, + setSession, +} from "@/lib/zitadel"; +import { ConnectError, Duration, timestampMs } from "@zitadel/client"; +import { + CredentialsCheckError, + CredentialsCheckErrorSchema, + ErrorDetail, +} from "@zitadel/proto/zitadel/message_pb"; +import { + Challenges, + RequestChallenges, +} from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +type CustomCookieData = { + id: string; + token: string; + loginName: string; + organization?: string; + creationTs: string; + expirationTs: string; + changeTs: string; + requestId?: string; // if its linked to an OIDC flow +}; + +const passwordAttemptsHandler = (error: ConnectError) => { + const details = error.findDetails(CredentialsCheckErrorSchema); + + if (details[0] && "failedAttempts" in details[0]) { + const failedAttempts = details[0].failedAttempts; + throw { + error: `Failed to authenticate: You had ${failedAttempts} password attempts.`, + failedAttempts: failedAttempts, + }; + } + throw error; +}; + +export async function createSessionAndUpdateCookie(command: { + checks: Checks; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const createdSession = await createSessionFromChecks({ + serviceUrl, + checks: command.checks, + lifetime: command.lifetime, + }); + + if (createdSession) { + return getSession({ + serviceUrl, + sessionId: createdSession.sessionId, + sessionToken: createdSession.sessionToken, + }).then(async (response) => { + if (response?.session && response.session?.factors?.user?.loginName) { + const sessionCookie: CustomCookieData = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + creationTs: response.session.creationDate + ? `${timestampMs(response.session.creationDate)}` + : "", + expirationTs: response.session.expirationDate + ? `${timestampMs(response.session.expirationDate)}` + : "", + changeTs: response.session.changeDate + ? `${timestampMs(response.session.changeDate)}` + : "", + loginName: response.session.factors.user.loginName ?? "", + }; + + if (command.requestId) { + sessionCookie.requestId = command.requestId; + } + + if (response.session.factors.user.organizationId) { + sessionCookie.organization = + response.session.factors.user.organizationId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + await addSessionToCookie({ session: sessionCookie, sameSite }); + + return response.session as Session; + } else { + throw "could not get session or session does not have loginName"; + } + }); + } else { + throw "Could not create session"; + } +} + +export async function createSessionForIdpAndUpdateCookie({ + userId, + idpIntent, + requestId, + lifetime, +}: { + userId: string; + idpIntent: { + idpIntentId?: string | undefined; + idpIntentToken?: string | undefined; + }; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const createdSession = await createSessionForUserIdAndIdpIntent({ + serviceUrl, + userId, + idpIntent, + lifetime, + }).catch((error: ErrorDetail | CredentialsCheckError) => { + console.error("Could not set session", error); + if ("failedAttempts" in error && error.failedAttempts) { + throw { + error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`, + failedAttempts: error.failedAttempts, + }; + } + throw error; + }); + + if (!createdSession) { + throw "Could not create session"; + } + + const { session } = await getSession({ + serviceUrl, + sessionId: createdSession.sessionId, + sessionToken: createdSession.sessionToken, + }); + + if (!session || !session.factors?.user?.loginName) { + throw "Could not retrieve session"; + } + + const sessionCookie: CustomCookieData = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + creationTs: session.creationDate + ? `${timestampMs(session.creationDate)}` + : "", + expirationTs: session.expirationDate + ? `${timestampMs(session.expirationDate)}` + : "", + changeTs: session.changeDate ? `${timestampMs(session.changeDate)}` : "", + loginName: session.factors.user.loginName ?? "", + organization: session.factors.user.organizationId ?? "", + }; + + if (requestId) { + sessionCookie.requestId = requestId; + } + + if (session.factors.user.organizationId) { + sessionCookie.organization = session.factors.user.organizationId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + return addSessionToCookie({ session: sessionCookie, sameSite }).then(() => { + return session as Session; + }); +} + +export type SessionWithChallenges = Session & { + challenges: Challenges | undefined; +}; + +export async function setSessionAndUpdateCookie( + recentCookie: CustomCookieData, + checks?: Checks, + challenges?: RequestChallenges, + requestId?: string, + lifetime?: Duration, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return setSession({ + serviceUrl, + sessionId: recentCookie.id, + sessionToken: recentCookie.token, + challenges, + checks, + lifetime, + }) + .then((updatedSession) => { + if (updatedSession) { + const sessionCookie: CustomCookieData = { + id: recentCookie.id, + token: updatedSession.sessionToken, + creationTs: recentCookie.creationTs, + expirationTs: recentCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: recentCookie.loginName, + organization: recentCookie.organization, + }; + + if (requestId) { + sessionCookie.requestId = requestId; + } + + return getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then(async (response) => { + if ( + !response?.session || + !response.session.factors?.user?.loginName + ) { + throw "could not get session or session does not have loginName"; + } + + const { session } = response; + const newCookie: CustomCookieData = { + id: sessionCookie.id, + token: updatedSession.sessionToken, + creationTs: sessionCookie.creationTs, + expirationTs: sessionCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: session.factors?.user?.loginName ?? "", + organization: session.factors?.user?.organizationId ?? "", + }; + + if (sessionCookie.requestId) { + newCookie.requestId = sessionCookie.requestId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + return updateSessionCookie({ + id: sessionCookie.id, + session: newCookie, + sameSite, + }).then(() => { + return { challenges: updatedSession.challenges, ...session }; + }); + }); + } else { + throw "Session not be set"; + } + }) + .catch(passwordAttemptsHandler); +} diff --git a/login/apps/login/src/lib/server/device.ts b/login/apps/login/src/lib/server/device.ts new file mode 100644 index 0000000000..5e36facfc8 --- /dev/null +++ b/login/apps/login/src/lib/server/device.ts @@ -0,0 +1,20 @@ +"use server"; + +import { authorizeOrDenyDeviceAuthorization } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function completeDeviceAuthorization( + deviceAuthorizationId: string, + session?: { sessionId: string; sessionToken: string }, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // without the session, device auth request is denied + return authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, + }); +} diff --git a/login/apps/login/src/lib/server/idp.ts b/login/apps/login/src/lib/server/idp.ts new file mode 100644 index 0000000000..aaf5b77779 --- /dev/null +++ b/login/apps/login/src/lib/server/idp.ts @@ -0,0 +1,176 @@ +"use server"; + +import { + getLoginSettings, + getUserByID, + startIdentityProviderFlow, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getNextUrl } from "../client"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { checkEmailVerification } from "../verify-helper"; +import { createSessionForIdpAndUpdateCookie } from "./cookie"; + +export type RedirectToIdpState = { error?: string | null } | undefined; + +export async function redirectToIdp( + prevState: RedirectToIdpState, + formData: FormData, +): Promise { + const params = new URLSearchParams(); + + const linkOnly = formData.get("linkOnly") === "true"; + const requestId = formData.get("requestId") as string; + const organization = formData.get("organization") as string; + const idpId = formData.get("id") as string; + const provider = formData.get("provider") as string; + + if (linkOnly) params.set("link", "true"); + if (requestId) params.set("requestId", requestId); + if (organization) params.set("organization", organization); + + const response = await startIDPFlow({ + idpId, + successUrl: `/idp/${provider}/success?` + params.toString(), + failureUrl: `/idp/${provider}/failure?` + params.toString(), + }); + + if (response && "error" in response && response?.error) { + return { error: response.error }; + } + + if (response && "redirect" in response && response?.redirect) { + redirect(response.redirect); + } +} + +export type StartIDPFlowCommand = { + idpId: string; + successUrl: string; + failureUrl: string; +}; + +export async function startIDPFlow(command: StartIDPFlowCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return startIdentityProviderFlow({ + serviceUrl, + idpId: command.idpId, + urls: { + successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.successUrl}`, + failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.failureUrl}`, + }, + }).then((response) => { + if ( + response && + response.nextStep.case === "authUrl" && + response?.nextStep.value + ) { + return { redirect: response.nextStep.value }; + } + }); +} + +type CreateNewSessionCommand = { + userId: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + loginName?: string; + password?: string; + organization?: string; + requestId?: string; +}; + +export async function createNewSessionFromIdpIntent( + command: CreateNewSessionCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.userId || !command.idpIntent) { + throw new Error("No userId or loginName provided"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!userResponse || !userResponse.user) { + return { error: "User not found in the system" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: userResponse.user.details?.resourceOwner, + }); + + const session = await createSessionForIdpAndUpdateCookie({ + userId: command.userId, + idpIntent: command.idpIntent, + requestId: command.requestId, + lifetime: loginSettings?.externalLoginCheckLifetime, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // TODO: check if user has MFA methods + // const mfaFactorCheck = checkMFAFactors(session, loginSettings, authMethods, organization, requestId); + // if (mfaFactorCheck?.redirect) { + // return mfaFactorCheck; + // } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + if (url) { + return { redirect: url }; + } +} diff --git a/login/apps/login/src/lib/server/loginname.ts b/login/apps/login/src/lib/server/loginname.ts new file mode 100644 index 0000000000..fa75929702 --- /dev/null +++ b/login/apps/login/src/lib/server/loginname.ts @@ -0,0 +1,450 @@ +"use server"; + +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; + +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + getActiveIdentityProviders, + getIDPByID, + getLoginSettings, + getOrgsByDomain, + listAuthenticationMethodTypes, + listIDPLinks, + searchUsers, + SearchUsersCommand, + startIdentityProviderFlow, +} from "../zitadel"; +import { createSessionAndUpdateCookie } from "./cookie"; + +export type SendLoginnameCommand = { + loginName: string; + requestId?: string; + organization?: string; + suffix?: string; +}; + +const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; + +export async function sendLoginname(command: SendLoginnameCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + throw new Error("Could not get domain"); + } + + const loginSettingsByContext = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + if (!loginSettingsByContext) { + return { error: "Could not get login settings" }; + } + + let searchUsersRequest: SearchUsersCommand = { + serviceUrl, + searchValue: command.loginName, + organizationId: command.organization, + loginSettings: loginSettingsByContext, + suffix: command.suffix, + }; + + const searchResult = await searchUsers(searchUsersRequest); + + if ("error" in searchResult && searchResult.error) { + return searchResult; + } + + if (!("result" in searchResult)) { + return { error: "Could not search users" }; + } + + const { result: potentialUsers } = searchResult; + + const redirectUserToSingleIDPIfAvailable = async () => { + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: command.organization, + }).then((resp) => { + return resp.identityProviders; + }); + + if (identityProviders.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const identityProviderType = identityProviders[0].type; + + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams(); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.organization) { + params.set("organization", command.organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const resp = await startIdentityProviderFlow({ + serviceUrl, + idpId: identityProviders[0].id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (resp?.nextStep.case === "authUrl") { + return { redirect: resp.nextStep.value }; + } + } + }; + + const redirectUserToIDP = async (userId: string) => { + const identityProviders = await listIDPLinks({ + serviceUrl, + userId, + }).then((resp) => { + return resp.result; + }); + + if (identityProviders.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const identityProviderId = identityProviders[0].idpId; + + const idp = await getIDPByID({ + serviceUrl, + id: identityProviderId, + }); + + const idpType = idp?.type; + + if (!idp || !idpType) { + throw new Error("Could not find identity provider"); + } + + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams({ userId }); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.organization) { + params.set("organization", command.organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const resp = await startIdentityProviderFlow({ + serviceUrl, + idpId: idp.id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (resp?.nextStep.case === "authUrl") { + return { redirect: resp.nextStep.value }; + } + } + }; + + if (potentialUsers.length > 1) { + return { error: "More than one user found. Provide a unique identifier." }; + } else if (potentialUsers.length == 1 && potentialUsers[0].userId) { + const user = potentialUsers[0]; + const userId = potentialUsers[0].userId; + + const userLoginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // compare with the concatenated suffix when set + const concatLoginname = command.suffix + ? `${command.loginName}@${command.suffix}` + : command.loginName; + + const humanUser = + potentialUsers[0].type.case === "human" + ? potentialUsers[0].type.value + : undefined; + + // recheck login settings after user discovery, as the search might have been done without org scope + if ( + userLoginSettings?.disableLoginWithEmail && + userLoginSettings?.disableLoginWithPhone + ) { + if (user.preferredLoginName !== concatLoginname) { + return { error: "User not found in the system!" }; + } + } else if (userLoginSettings?.disableLoginWithEmail) { + if ( + user.preferredLoginName !== concatLoginname || + humanUser?.phone?.phone !== command.loginName + ) { + return { error: "User not found in the system!" }; + } + } else if (userLoginSettings?.disableLoginWithPhone) { + if ( + user.preferredLoginName !== concatLoginname || + humanUser?.email?.email !== command.loginName + ) { + return { error: "User not found in the system!" }; + } + } + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: userId } }, + }); + + const session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + + if (!session.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + // TODO: check if handling of userstate INITIAL is needed + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + const methods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + // always resend invite if user has no auth method set + if (!methods.authMethodTypes || !methods.authMethodTypes.length) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true to request a new code immediately + invite: "true", + }); + + if (command.requestId) { + params.append("requestId", command.requestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? + (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } + + if (methods.authMethodTypes.length == 1) { + const method = methods.authMethodTypes[0]; + switch (method) { + case AuthenticationMethodType.PASSWORD: // user has only password as auth method + if (!userLoginSettings?.allowUsernamePassword) { + return { + error: + "Username Password not allowed! Contact your administrator for more information.", + }; + } + + const paramsPassword: any = { + loginName: session.factors?.user?.loginName, + }; + + // TODO: does this have to be checked in loginSettings.allowDomainDiscovery + + if (command.organization || session.factors?.user?.organizationId) { + paramsPassword.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + if (command.requestId) { + paramsPassword.requestId = command.requestId; + } + + return { + redirect: "/password?" + new URLSearchParams(paramsPassword), + }; + + case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY + if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) { + return { + error: + "Passkeys not allowed! Contact your administrator for more information.", + }; + } + + const paramsPasskey: any = { loginName: command.loginName }; + if (command.requestId) { + paramsPasskey.requestId = command.requestId; + } + + if (command.organization || session.factors?.user?.organizationId) { + paramsPasskey.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + return { redirect: "/passkey?" + new URLSearchParams(paramsPasskey) }; + } + } else { + // prefer passkey in favor of other methods + if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) { + const passkeyParams: any = { + loginName: command.loginName, + altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option + }; + + if (command.requestId) { + passkeyParams.requestId = command.requestId; + } + + if (command.organization || session.factors?.user?.organizationId) { + passkeyParams.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + return { redirect: "/passkey?" + new URLSearchParams(passkeyParams) }; + } else if ( + methods.authMethodTypes.includes(AuthenticationMethodType.IDP) + ) { + return redirectUserToIDP(userId); + } else if ( + methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) + ) { + // user has no passkey setup and login settings allow passkeys + const paramsPasswordDefault: any = { loginName: command.loginName }; + + if (command.requestId) { + paramsPasswordDefault.requestId = command.requestId; + } + + if (command.organization || session.factors?.user?.organizationId) { + paramsPasswordDefault.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + return { + redirect: "/password?" + new URLSearchParams(paramsPasswordDefault), + }; + } + } + } + + // user not found, check if register is enabled on instance / organization context + if ( + loginSettingsByContext?.allowRegister && + !loginSettingsByContext?.allowUsernamePassword + ) { + const resp = await redirectUserToSingleIDPIfAvailable(); + if (resp) { + return resp; + } + return { error: "User not found in the system" }; + } else if ( + loginSettingsByContext?.allowRegister && + loginSettingsByContext?.allowUsernamePassword + ) { + let orgToRegisterOn: string | undefined = command.organization; + + if ( + !loginSettingsByContext?.ignoreUnknownUsernames && + !orgToRegisterOn && + command.loginName && + ORG_SUFFIX_REGEX.test(command.loginName) + ) { + const matched = ORG_SUFFIX_REGEX.exec(command.loginName); + const suffix = matched?.[1] ?? ""; + + // this just returns orgs where the suffix is set as primary domain + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = + orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + orgToRegisterOn = orgToCheckForDiscovery; + } + } + + // do not register user if ignoreUnknownUsernames is set + if (orgToRegisterOn && !loginSettingsByContext?.ignoreUnknownUsernames) { + const params = new URLSearchParams({ organization: orgToRegisterOn }); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.loginName) { + params.set("email", command.loginName); + } + + return { redirect: "/register?" + params }; + } + } + + if (loginSettingsByContext?.ignoreUnknownUsernames) { + const paramsPasswordDefault = new URLSearchParams({ + loginName: command.loginName, + }); + + if (command.requestId) { + paramsPasswordDefault.append("requestId", command.requestId); + } + + if (command.organization) { + paramsPasswordDefault.append("organization", command.organization); + } + + return { redirect: "/password?" + paramsPasswordDefault }; + } + + // fallbackToPassword + + return { error: "User not found in the system" }; +} diff --git a/login/apps/login/src/lib/server/oidc.ts b/login/apps/login/src/lib/server/oidc.ts new file mode 100644 index 0000000000..36a31fe419 --- /dev/null +++ b/login/apps/login/src/lib/server/oidc.ts @@ -0,0 +1,15 @@ +"use server"; + +import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function getDeviceAuthorizationRequest(userCode: string) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelGetDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); +} diff --git a/login/apps/login/src/lib/server/otp.ts b/login/apps/login/src/lib/server/otp.ts new file mode 100644 index 0000000000..f3d4a1536a --- /dev/null +++ b/login/apps/login/src/lib/server/otp.ts @@ -0,0 +1,83 @@ +"use server"; + +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { create } from "@zitadel/client"; +import { + CheckOTPSchema, + ChecksSchema, + CheckTOTPSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { getLoginSettings } from "../zitadel"; + +export type SetOTPCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + requestId?: string; + code: string; + method: string; +}; + +export async function setOTP(command: SetOTPCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const recentSession = command.sessionId + ? await getSessionCookieById({ sessionId: command.sessionId }).catch( + (error) => { + return Promise.reject(error); + }, + ) + : command.loginName + ? await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + return Promise.reject(error); + }) + : await getMostRecentSessionCookie().catch((error) => { + return Promise.reject(error); + }); + + const checks = create(ChecksSchema, {}); + + if (command.method === "time-based") { + checks.totp = create(CheckTOTPSchema, { + code: command.code, + }); + } else if (command.method === "sms") { + checks.otpSms = create(CheckOTPSchema, { + code: command.code, + }); + } else if (command.method === "email") { + checks.otpEmail = create(CheckOTPSchema, { + code: command.code, + }); + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + return setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + command.requestId, + loginSettings?.secondFactorCheckLifetime, + ).then((session) => { + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + }; + }); +} diff --git a/login/apps/login/src/lib/server/passkeys.ts b/login/apps/login/src/lib/server/passkeys.ts new file mode 100644 index 0000000000..3470629f24 --- /dev/null +++ b/login/apps/login/src/lib/server/passkeys.ts @@ -0,0 +1,278 @@ +"use server"; + +import { + createPasskeyRegistrationLink, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + registerPasskey, + verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration, +} from "@/lib/zitadel"; +import { create, Duration, Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { + RegisterPasskeyResponse, + VerifyPasskeyRegistrationRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { userAgent } from "next/server"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + checkEmailVerification, + checkUserVerification, +} from "../verify-helper"; +import { setSessionAndUpdateCookie } from "./cookie"; + +type VerifyPasskeyCommand = { + passkeyId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +type RegisterPasskeyCommand = { + sessionId: string; +}; + +function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey; + const valid = !!((validPassword || validPasskey) && stillValid); + + return { valid, verifiedAt }; +} + +export async function registerPasskeyLink( + command: RegisterPasskeyCommand, +): Promise { + const { sessionId } = command; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + throw new Error("Could not get domain"); + } + + const sessionCookie = await getSessionCookieById({ sessionId }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session?.session?.factors?.user?.id) { + return { error: "Could not determine user from session" }; + } + + const sessionValid = isSessionValid(session.session); + + if (!sessionValid) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.session.factors.user.id, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to authenticate or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + session.session.factors.user.id, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + + const [hostname, port] = host.split(":"); + + if (!hostname) { + throw new Error("Could not get hostname"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + // TODO: add org context + + // use session token to add the passkey + const registerLink = await createPasskeyRegistrationLink({ + serviceUrl, + userId, + }); + + if (!registerLink.code) { + throw new Error("Missing code in response"); + } + + return registerPasskey({ + serviceUrl, + userId, + code: registerLink.code, + domain: hostname, + }); +} + +export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // if no name is provided, try to generate one from the user agent + let passkeyName = command.passkeyName; + if (!!!passkeyName) { + const headersList = await headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + + return zitadelVerifyPasskeyRegistration({ + serviceUrl, + request: create(VerifyPasskeyRegistrationRequestSchema, { + passkeyId: command.passkeyId, + publicKeyCredential: command.publicKeyCredential, + passkeyName, + userId, + }), + }); +} + +type SendPasskeyCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + requestId?: string; + lifetime?: Duration; +}; + +export async function sendPasskey(command: SendPasskeyCommand) { + let { loginName, sessionId, organization, checks, requestId } = command; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + requestId, + lifetime, + ); + + if (!session || !session?.factors?.user?.id) { + return { error: "Could not update session" }; + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + organization, + requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + const url = + requestId && session.id + ? await getNextUrl( + { + sessionId: session.id, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : session?.factors?.user?.loginName + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + return { redirect: url }; +} diff --git a/login/apps/login/src/lib/server/password.ts b/login/apps/login/src/lib/server/password.ts new file mode 100644 index 0000000000..3786145157 --- /dev/null +++ b/login/apps/login/src/lib/server/password.ts @@ -0,0 +1,462 @@ +"use server"; + +import { + createSessionAndUpdateCookie, + setSessionAndUpdateCookie, +} from "@/lib/server/cookie"; +import { + getLockoutSettings, + getLoginSettings, + getPasswordExpirySettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + listUsers, + passwordReset, + setPassword, + setUserPassword, +} from "@/lib/zitadel"; +import { ConnectError, create } from "@zitadel/client"; +import { createServerTransport } from "@zitadel/client/node"; +import { createUserServiceClient } from "@zitadel/client/v2"; +import { + Checks, + ChecksSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + AuthenticationMethodType, + SetPasswordRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + checkEmailVerification, + checkMFAFactors, + checkPasswordChangeRequired, + checkUserVerification, +} from "../verify-helper"; + +type ResetPasswordCommand = { + loginName: string; + organization?: string; + requestId?: string; +}; + +export async function resetPassword(command: ResetPasswordCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const users = await listUsers({ + serviceUrl, + loginName: command.loginName, + organizationId: command.organization, + }); + + if ( + !users.details || + users.details.totalResult !== BigInt(1) || + !users.result[0].userId + ) { + return { error: "Could not send Password Reset Link" }; + } + const userId = users.result[0].userId; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return passwordReset({ + serviceUrl, + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }); +} + +export type UpdateSessionCommand = { + loginName: string; + organization?: string; + checks: Checks; + requestId?: string; +}; + +export async function sendPassword(command: UpdateSessionCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let sessionCookie = await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); + }); + + let session; + let user: User; + let loginSettings: LoginSettings | undefined; + + if (!sessionCookie) { + const users = await listUsers({ + serviceUrl, + loginName: command.loginName, + organizationId: command.organization, + }); + + if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { + user = users.result[0]; + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: users.result[0].userId } }, + password: { password: command.checks.password?.password }, + }); + + loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + try { + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + lifetime: loginSettings?.passwordCheckLifetime, + }); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + orgId: command.organization, + }); + + return { + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? "Contact your administrator to unlock your account" + : ""), + }; + } + return { error: "Could not create session for user" }; + } + } + + // this is a fake error message to hide that the user does not even exist + return { error: "Could not verify password" }; + } else { + try { + session = await setSessionAndUpdateCookie( + sessionCookie, + command.checks, + undefined, + command.requestId, + loginSettings?.passwordCheckLifetime, + ); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + orgId: command.organization, + }); + + return { + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? " Contact your administrator to unlock your account" + : ""), + }; + } + throw error; + } + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + user = userResponse.user; + } + + if (!loginSettings) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization: + command.organization ?? session.factors?.user?.organizationId, + }); + } + + if (!session?.factors?.user?.id || !sessionCookie) { + return { error: "Could not create session for user" }; + } + + const humanUser = user.type.case === "human" ? user.type.value : undefined; + + const expirySettings = await getPasswordExpirySettings({ + serviceUrl, + orgId: command.organization ?? session.factors?.user?.organizationId, + }); + + // check if the user has to change password first + const passwordChangedCheck = checkPasswordChangeRequired( + expirySettings, + session, + humanUser, + command.organization, + command.requestId, + ); + + if (passwordChangedCheck?.redirect) { + return passwordChangedCheck; + } + + // throw error if user is in initial state here and do not continue + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // if password, check if user has MFA methods + let authMethods; + if (command.checks && command.checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + if (!authMethods) { + return { error: "Could not verify password!" }; + } + + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethods, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} + +// this function lets users with code set a password or users with valid User Verification Check +export async function changePassword(command: { + code?: string; + userId: string; + password: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // check for init state + const { user } = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!user || user.userId !== command.userId) { + return { error: "Could not send Password Reset Link" }; + } + const userId = user.userId; + + if (user.state === UserState.INITIAL) { + return { error: "User Initial State is not supported" }; + } + + // check if the user has no password set in order to set a password + if (!command.code) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to provide a code or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + user.userId, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + + return setUserPassword({ + serviceUrl, + userId, + password: command.password, + code: command.code, + }); +} + +type CheckSessionAndSetPasswordCommand = { + sessionId: string; + password: string; +}; + +export async function checkSessionAndSetPassword({ + sessionId, + password, +}: CheckSessionAndSetPasswordCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session || !session.factors?.user?.id) { + return { error: "Could not load session" }; + } + + const payload = create(SetPasswordRequestSchema, { + userId: session.factors.user.id, + newPassword: { + password, + }, + }); + + // check if the user has no password set in order to set a password + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + if (!authmethods) { + return { error: "Could not load auth methods" }; + } + + const requiredAuthMethodsForForceMFA = [ + AuthenticationMethodType.OTP_EMAIL, + AuthenticationMethodType.OTP_SMS, + AuthenticationMethodType.TOTP, + AuthenticationMethodType.U2F, + ]; + + const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every( + (method) => !authmethods.authMethodTypes.includes(method), + ); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors.user.organizationId, + }); + + const forceMfa = !!( + loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly + ); + + // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user + if (forceMfa && hasNoMFAMethods) { + return setPassword({ serviceUrl, payload }).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: "Failed precondition" }; + } else { + throw error; + } + }); + } else { + const transport = async (serviceUrl: string, token: string) => { + return createServerTransport(token, { + baseUrl: serviceUrl, + }); + }; + + const myUserService = async (serviceUrl: string, sessionToken: string) => { + const transportPromise = await transport(serviceUrl, sessionToken); + return createUserServiceClient(transportPromise); + }; + + const selfService = await myUserService( + serviceUrl, + `${sessionCookie.token}`, + ); + + return selfService + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error: ConnectError) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); + } +} diff --git a/login/apps/login/src/lib/server/register.ts b/login/apps/login/src/lib/server/register.ts new file mode 100644 index 0000000000..f84b4c8d51 --- /dev/null +++ b/login/apps/login/src/lib/server/register.ts @@ -0,0 +1,233 @@ +"use server"; + +import { + createSessionAndUpdateCookie, + createSessionForIdpAndUpdateCookie, +} from "@/lib/server/cookie"; +import { + addHumanUser, + addIDPLink, + getLoginSettings, + getUserByID, +} from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { + ChecksJson, + ChecksSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { checkEmailVerification } from "../verify-helper"; + +type RegisterUserCommand = { + email: string; + firstName: string; + lastName: string; + password?: string; + organization: string; + requestId?: string; +}; + +export type RegisterUserResponse = { + userId: string; + sessionId: string; + factors: Factors | undefined; +}; +export async function registerUser(command: RegisterUserCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const addResponse = await addHumanUser({ + serviceUrl, + email: command.email, + firstName: command.firstName, + lastName: command.lastName, + password: command.password ? command.password : undefined, + organization: command.organization, + }); + + if (!addResponse) { + return { error: "Could not create user" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + let checkPayload: any = { + user: { search: { case: "userId", value: addResponse.userId } }, + }; + + if (command.password) { + checkPayload = { + ...checkPayload, + password: { password: command.password }, + } as ChecksJson; + } + + const checks = create(ChecksSchema, checkPayload); + + const session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + lifetime: command.password + ? loginSettings?.passwordCheckLifetime + : undefined, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + if (!command.password) { + const params = new URLSearchParams({ + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }); + + if (command.requestId) { + params.append("requestId", command.requestId); + } + + return { redirect: "/passkey/set?" + params }; + } else { + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + session.factors.user.organizationId, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; + } +} + +type RegisterUserAndLinkToIDPommand = { + email: string; + firstName: string; + lastName: string; + organization: string; + requestId?: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + idpUserId: string; + idpId: string; + idpUserName: string; +}; + +export type registerUserAndLinkToIDPResponse = { + userId: string; + sessionId: string; + factors: Factors | undefined; +}; +export async function registerUserAndLinkToIDP( + command: RegisterUserAndLinkToIDPommand, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const addResponse = await addHumanUser({ + serviceUrl, + email: command.email, + firstName: command.firstName, + lastName: command.lastName, + organization: command.organization, + }); + + if (!addResponse) { + return { error: "Could not create user" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + const idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: command.idpId, + userId: command.idpUserId, + userName: command.idpUserName, + }, + userId: addResponse.userId, + }); + + if (!idpLink) { + return { error: "Could not link IDP to user" }; + } + + const session = await createSessionForIdpAndUpdateCookie({ + requestId: command.requestId, + userId: addResponse.userId, // the user we just created + idpIntent: command.idpIntent, + lifetime: loginSettings?.externalLoginCheckLifetime, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} diff --git a/login/apps/login/src/lib/server/session.ts b/login/apps/login/src/lib/server/session.ts new file mode 100644 index 0000000000..2aceb3a1d0 --- /dev/null +++ b/login/apps/login/src/lib/server/session.ts @@ -0,0 +1,221 @@ +"use server"; + +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { + deleteSession, + getLoginSettings, + getSecuritySettings, + humanMFAInitSkipped, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Duration } from "@zitadel/client"; +import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, + removeSessionFromCookie, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function skipMFAAndContinueWithNextUrl({ + userId, + requestId, + loginName, + sessionId, + organization, +}: { + userId: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization, + }); + + await humanMFAInitSkipped({ serviceUrl, userId }); + + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + +export async function continueWithSession({ + requestId, + ...session +}: Session & { requestId?: string }) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors?.user?.organizationId, + }); + + const url = + requestId && session.id && session.factors?.user + ? await getNextUrl( + { + sessionId: session.id, + requestId: requestId, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : session.factors?.user + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + +export type UpdateSessionCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + requestId?: string; + challenges?: RequestChallenges; + lifetime?: Duration; +}; + +export async function updateSession(options: UpdateSessionCommand) { + let { loginName, sessionId, organization, checks, requestId, challenges } = + options; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + if ( + host && + challenges && + challenges.webAuthN && + !challenges.webAuthN.domain + ) { + const [hostname, port] = host.split(":"); + + challenges.webAuthN.domain = hostname; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + challenges, + requestId, + lifetime, + ); + + if (!session) { + return { error: "Could not update session" }; + } + + // if password, check if user has MFA methods + let authMethods; + if (checks && checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + authMethods, + }; +} + +type ClearSessionOptions = { + sessionId: string; +}; + +export async function clearSession(options: ClearSessionOptions) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { sessionId } = options; + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const deleteResponse = await deleteSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + if (!deleteResponse) { + throw new Error("Could not delete session"); + } + + return removeSessionFromCookie({ session: sessionCookie, sameSite }); +} diff --git a/login/apps/login/src/lib/server/u2f.ts b/login/apps/login/src/lib/server/u2f.ts new file mode 100644 index 0000000000..3fe5194336 --- /dev/null +++ b/login/apps/login/src/lib/server/u2f.ts @@ -0,0 +1,103 @@ +"use server"; + +import { getSession, registerU2F, verifyU2FRegistration } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { VerifyU2FRegistrationRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { userAgent } from "next/server"; +import { getSessionCookieById } from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; + +type RegisterU2FCommand = { + sessionId: string; +}; + +type VerifyU2FCommand = { + u2fId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +export async function addU2F(command: RegisterU2FCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + + if (!sessionCookie) { + return { error: "Could not get session" }; + } + + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const [hostname, port] = host.split(":"); + + if (!hostname) { + throw new Error("Could not get hostname"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!session || !userId) { + return { error: "Could not get session" }; + } + + return registerU2F({ serviceUrl, userId, domain: hostname }); +} + +export async function verifyU2F(command: VerifyU2FCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + let passkeyName = command.passkeyName; + if (!!!passkeyName) { + const headersList = await headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + return { error: "Could not get session" }; + } + + const request = create(VerifyU2FRegistrationRequestSchema, { + u2fId: command.u2fId, + publicKeyCredential: command.publicKeyCredential, + tokenName: passkeyName, + userId, + }); + + return verifyU2FRegistration({ serviceUrl, request }); +} diff --git a/login/apps/login/src/lib/server/verify.ts b/login/apps/login/src/lib/server/verify.ts new file mode 100644 index 0000000000..cf60f739b3 --- /dev/null +++ b/login/apps/login/src/lib/server/verify.ts @@ -0,0 +1,329 @@ +"use server"; + +import { + createInviteCode, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + verifyEmail, + verifyInviteCode, + verifyTOTPRegistration, + sendEmailCode as zitadelSendEmailCode, +} from "@/lib/zitadel"; +import crypto from "crypto"; + +import { create } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { cookies, headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getSessionCookieByLoginName } from "../cookies"; +import { getOrSetFingerprintId } from "../fingerprint"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { loadMostRecentSession } from "../session"; +import { checkMFAFactors } from "../verify-helper"; +import { createSessionAndUpdateCookie } from "./cookie"; + +export async function verifyTOTP( + code: string, + loginName?: string, + organization?: string, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + if (session?.factors?.user?.id) { + return verifyTOTPRegistration({ + serviceUrl, + code, + userId: session.factors.user.id, + }); + } else { + throw Error("No user id found in session."); + } + }); +} + +type VerifyUserByEmailCommand = { + userId: string; + loginName?: string; // to determine already existing session + organization?: string; + code: string; + isInvite: boolean; + requestId?: string; +}; + +export async function sendVerification(command: VerifyUserByEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const verifyResponse = command.isInvite + ? await verifyInviteCode({ + serviceUrl, + userId: command.userId, + verificationCode: command.code, + }).catch((error) => { + console.warn(error); + return { error: "Could not verify invite" }; + }) + : await verifyEmail({ + serviceUrl, + userId: command.userId, + verificationCode: command.code, + }).catch((error) => { + console.warn(error); + return { error: "Could not verify email" }; + }); + + if ("error" in verifyResponse) { + return verifyResponse; + } + + if (!verifyResponse) { + return { error: "Could not verify" }; + } + + let session: Session | undefined; + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!userResponse || !userResponse.user) { + return { error: "Could not load user" }; + } + + const user = userResponse.user; + + const sessionCookie = await getSessionCookieByLoginName({ + loginName: + "loginName" in command ? command.loginName : user.preferredLoginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); // checked later + }); + + if (sessionCookie) { + session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + // load auth methods for user + const authMethodResponse = await listAuthenticationMethodTypes({ + serviceUrl, + userId: user.userId, + }); + + if (!authMethodResponse || !authMethodResponse.authMethodTypes) { + return { error: "Could not load possible authenticators" }; + } + + // if no authmethods are found on the user, redirect to set one up + if ( + authMethodResponse && + authMethodResponse.authMethodTypes && + authMethodResponse.authMethodTypes.length == 0 + ) { + if (!sessionCookie) { + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + } + + if (!session) { + return { error: "Could not create session" }; + } + + const params = new URLSearchParams({ + sessionId: session.id, + }); + + if (session.factors?.user?.loginName) { + params.set("loginName", session.factors?.user?.loginName); + } + + // set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId + const cookiesList = await cookies(); + const userAgentId = await getOrSetFingerprintId(); + + const verificationCheck = crypto + .createHash("sha256") + .update(`${user.userId}:${userAgentId}`) + .digest("hex"); + + await cookiesList.set({ + name: "verificationCheck", + value: verificationCheck, + httpOnly: true, + path: "/", + maxAge: 300, // 5 minutes + }); + + return { redirect: `/authenticator/set?${params}` }; + } + + // if no session found only show success page, + // if user is invited, recreate invite flow to not depend on session + if (!session?.factors?.user?.id) { + const verifySuccessParams = new URLSearchParams({}); + + if (command.userId) { + verifySuccessParams.set("userId", command.userId); + } + + if ( + ("loginName" in command && command.loginName) || + user.preferredLoginName + ) { + verifySuccessParams.set( + "loginName", + "loginName" in command && command.loginName + ? command.loginName + : user.preferredLoginName, + ); + } + if (command.requestId) { + verifySuccessParams.set("requestId", command.requestId); + } + if (command.organization) { + verifySuccessParams.set("organization", command.organization); + } + + return { redirect: `/verify/success?${verifySuccessParams}` }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // redirect to mfa factor if user has one, or redirect to set one up + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethodResponse.authMethodTypes, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + // login user if no additional steps are required + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} + +type resendVerifyEmailCommand = { + userId: string; + isInvite: boolean; + requestId?: string; +}; + +export async function resendVerification(command: resendVerifyEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "No host found" }; + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return command.isInvite + ? createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }).catch((error) => { + if (error.code === 9) { + return { error: "User is already verified!" }; + } + return { error: "Could not resend invite" }; + }) + : zitadelSendEmailCode({ + userId: command.userId, + serviceUrl, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }); +} + +type SendEmailCommand = { + userId: string; + urlTemplate: string; +}; + +export async function sendEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelSendEmailCode({ + serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} + +export async function sendInviteEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} diff --git a/login/apps/login/src/lib/service-url.ts b/login/apps/login/src/lib/service-url.ts new file mode 100644 index 0000000000..e74ee1f333 --- /dev/null +++ b/login/apps/login/src/lib/service-url.ts @@ -0,0 +1,58 @@ +import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; +import { NextRequest } from "next/server"; + +/** + * Extracts the service url and region from the headers if used in a multitenant context (host, x-zitadel-forward-host header) + * or falls back to the ZITADEL_API_URL for a self hosting deployment + * or falls back to the host header for a self hosting deployment using custom domains + * @param headers + * @returns the service url and region from the headers + * @throws if the service url could not be determined + * + */ +export function getServiceUrlFromHeaders(headers: ReadonlyHeaders): { + serviceUrl: string; +} { + let instanceUrl; + + const forwardedHost = headers.get("x-zitadel-forward-host"); + // use the forwarded host if available (multitenant), otherwise fall back to the host of the deployment itself + if (forwardedHost) { + instanceUrl = forwardedHost; + instanceUrl = instanceUrl.startsWith("http://") + ? instanceUrl + : `https://${instanceUrl}`; + } else if (process.env.ZITADEL_API_URL) { + instanceUrl = process.env.ZITADEL_API_URL; + } else { + const host = headers.get("host"); + + if (host) { + const [hostname, port] = host.split(":"); + if (hostname !== "localhost") { + instanceUrl = host.startsWith("http") ? host : `https://${host}`; + } + } + } + + if (!instanceUrl) { + throw new Error("Service URL could not be determined"); + } + + return { + serviceUrl: instanceUrl, + }; +} + +export function constructUrl(request: NextRequest, path: string) { + const forwardedProto = request.headers.get("x-forwarded-proto") + ? `${request.headers.get("x-forwarded-proto")}:` + : request.nextUrl.protocol; + + const forwardedHost = + request.headers.get("x-zitadel-forward-host") ?? + request.headers.get("x-forwarded-host") ?? + request.headers.get("host"); + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""; + return new URL(`${basePath}${path}`, `${forwardedProto}//${forwardedHost}`); +} diff --git a/login/apps/login/src/lib/service.ts b/login/apps/login/src/lib/service.ts new file mode 100644 index 0000000000..0fbb083b05 --- /dev/null +++ b/login/apps/login/src/lib/service.ts @@ -0,0 +1,66 @@ +import { createClientFor } from "@zitadel/client"; +import { createServerTransport } from "@zitadel/client/node"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; +import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; +import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { systemAPIToken } from "./api"; + +type ServiceClass = + | typeof IdentityProviderService + | typeof UserService + | typeof OrganizationService + | typeof SessionService + | typeof OIDCService + | typeof SettingsService + | typeof SAMLService; + +export async function createServiceForHost( + service: T, + serviceUrl: string, +) { + let token; + + // if we are running in a multitenancy context, use the system user token + if ( + process.env.AUDIENCE && + process.env.SYSTEM_USER_ID && + process.env.SYSTEM_USER_PRIVATE_KEY + ) { + token = await systemAPIToken(); + } else if (process.env.ZITADEL_SERVICE_USER_TOKEN) { + token = process.env.ZITADEL_SERVICE_USER_TOKEN; + } + + if (!serviceUrl) { + throw new Error("No instance url found"); + } + + if (!token) { + throw new Error("No token found"); + } + + const transport = createServerTransport(token, { + baseUrl: serviceUrl, + interceptors: !process.env.CUSTOM_REQUEST_HEADERS + ? undefined + : [ + (next) => { + return (req) => { + process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach( + (header) => { + const kv = header.split(":"); + req.header.set(kv[0], kv[1]); + }, + ); + return next(req); + }; + }, + ], + }); + + return createClientFor(service)(transport); +} diff --git a/login/apps/login/src/lib/session.ts b/login/apps/login/src/lib/session.ts new file mode 100644 index 0000000000..9698c4c4ba --- /dev/null +++ b/login/apps/login/src/lib/session.ts @@ -0,0 +1,194 @@ +import { timestampDate } from "@zitadel/client"; +import { AuthRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { SAMLRequest } from "@zitadel/proto/zitadel/saml/v2/authorization_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getMostRecentCookieWithLoginname } from "./cookies"; +import { + getLoginSettings, + getSession, + listAuthenticationMethodTypes, +} from "./zitadel"; + +type LoadMostRecentSessionParams = { + serviceUrl: string; + + sessionParams: { + loginName?: string; + organization?: string; + }; +}; + +export async function loadMostRecentSession({ + serviceUrl, + sessionParams, +}: LoadMostRecentSessionParams): Promise { + const recent = await getMostRecentCookieWithLoginname({ + loginName: sessionParams.loginName, + organization: sessionParams.organization, + }); + + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((resp: GetSessionResponse) => resp.session); +} + +/** + * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) + * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); + **/ +export async function isSessionValid({ + serviceUrl, + session, +}: { + serviceUrl: string; + session: Session; +}): Promise { + // session can't be checked without user + if (!session.factors?.user) { + console.warn("Session has no user"); + return false; + } + + let mfaValid = true; + + const authMethodTypes = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + const authMethods = authMethodTypes.authMethodTypes; + if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) { + mfaValid = !!session.factors.totp?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid totpEmail factor", + session.factors.totp?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.OTP_EMAIL) + ) { + mfaValid = !!session.factors.otpEmail?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid otpEmail factor", + session.factors.otpEmail?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.OTP_SMS) + ) { + mfaValid = !!session.factors.otpSms?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid otpSms factor", + session.factors.otpSms?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.U2F) + ) { + mfaValid = !!session.factors.webAuthN?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid u2f factor", + session.factors.webAuthN?.verifiedAt, + ); + } + } else { + // only check settings if no auth methods are available, as this would require a setup + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors?.user?.organizationId, + }); + if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) { + const otpEmail = session.factors.otpEmail?.verifiedAt; + const otpSms = session.factors.otpSms?.verifiedAt; + const totp = session.factors.totp?.verifiedAt; + const webAuthN = session.factors.webAuthN?.verifiedAt; + const idp = session.factors.intent?.verifiedAt; // TODO: forceMFA should not consider this as valid factor + + // must have one single check + mfaValid = !!(otpEmail || otpSms || totp || webAuthN || idp); + if (!mfaValid) { + console.warn("Session has no valid multifactor", session.factors); + } + } else { + mfaValid = true; + } + } + + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const validIDP = session?.factors?.intent?.verifiedAt; + + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate).getTime() > new Date().getTime() + : true; + + if (!stillValid) { + console.warn( + "Session is expired", + session.expirationDate + ? timestampDate(session.expirationDate).toDateString() + : "no expiration date", + ); + } + + const validChecks = !!(validPassword || validPasskey || validIDP); + + return stillValid && validChecks && mfaValid; +} + +export async function findValidSession({ + serviceUrl, + sessions, + authRequest, + samlRequest, +}: { + serviceUrl: string; + sessions: Session[]; + authRequest?: AuthRequest; + samlRequest?: SAMLRequest; +}): Promise { + const sessionsWithHint = sessions.filter((s) => { + if (authRequest && authRequest.hintUserId) { + return s.factors?.user?.id === authRequest.hintUserId; + } + if (authRequest && authRequest.loginHint) { + return s.factors?.user?.loginName === authRequest.loginHint; + } + if (samlRequest) { + // TODO: do whatever + return true; + } + return true; + }); + + if (sessionsWithHint.length === 0) { + return undefined; + } + + // sort by change date descending + sessionsWithHint.sort((a, b) => { + const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0; + const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0; + return dateB - dateA; + }); + + // return the first valid session according to settings + for (const session of sessionsWithHint) { + if (await isSessionValid({ serviceUrl, session })) { + return session; + } + } + + return undefined; +} diff --git a/login/apps/login/src/lib/verify-helper.ts b/login/apps/login/src/lib/verify-helper.ts new file mode 100644 index 0000000000..dbd9b2796b --- /dev/null +++ b/login/apps/login/src/lib/verify-helper.ts @@ -0,0 +1,289 @@ +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import crypto from "crypto"; +import moment from "moment"; +import { cookies } from "next/headers"; +import { getFingerprintIdCookie } from "./fingerprint"; +import { getUserByID } from "./zitadel"; + +export function checkPasswordChangeRequired( + expirySettings: PasswordExpirySettings | undefined, + session: Session, + humanUser: HumanUser | undefined, + organization?: string, + requestId?: string, +) { + let isOutdated = false; + if (expirySettings?.maxAgeDays && humanUser?.passwordChanged) { + const maxAgeDays = Number(expirySettings.maxAgeDays); // Convert bigint to number + const passwordChangedDate = moment( + timestampDate(humanUser.passwordChanged), + ); + const outdatedPassword = passwordChangedDate.add(maxAgeDays, "days"); + isOutdated = moment().isAfter(outdatedPassword); + } + + if (humanUser?.passwordChangeRequired || isOutdated) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + session.factors?.user?.organizationId as string, + ); + } + + if (requestId) { + params.append("requestId", requestId); + } + + return { redirect: "/password/change?" + params }; + } +} + +export function checkEmailVerified( + session: Session, + humanUser?: HumanUser, + organization?: string, + requestId?: string, +) { + if (!humanUser?.email?.isVerified) { + const paramsVerify = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + userId: session.factors?.user?.id as string, // verify needs user id + send: "true", // we request a new email code once the page is loaded + }); + + if (organization || session.factors?.user?.organizationId) { + paramsVerify.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + if (requestId) { + paramsVerify.append("requestId", requestId); + } + + return { redirect: "/verify?" + paramsVerify }; + } +} + +export function checkEmailVerification( + session: Session, + humanUser?: HumanUser, + organization?: string, + requestId?: string, +) { + if ( + !humanUser?.email?.isVerified && + process.env.EMAIL_VERIFICATION === "true" + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true as we dont expect old email codes to be valid anymore + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } +} + +export async function checkMFAFactors( + serviceUrl: string, + session: Session, + loginSettings: LoginSettings | undefined, + authMethods: AuthenticationMethodType[], + organization?: string, + requestId?: string, +) { + const availableMultiFactors = authMethods?.filter( + (m: AuthenticationMethodType) => + m !== AuthenticationMethodType.PASSWORD && + m !== AuthenticationMethodType.PASSKEY, + ); + + const hasAuthenticatedWithPasskey = + session.factors?.webAuthN?.verifiedAt && + session.factors?.webAuthN?.userVerified; + + // escape further checks if user has authenticated with passkey + if (hasAuthenticatedWithPasskey) { + return; + } + + // if user has not authenticated with passkey and has only one additional mfa factor, redirect to that + if (availableMultiFactors?.length == 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + const factor = availableMultiFactors[0]; + // if passwordless is other method, but user selected password as alternative, perform a login + if (factor === AuthenticationMethodType.TOTP) { + return { redirect: `/otp/time-based?` + params }; + } else if (factor === AuthenticationMethodType.OTP_SMS) { + return { redirect: `/otp/sms?` + params }; + } else if (factor === AuthenticationMethodType.OTP_EMAIL) { + return { redirect: `/otp/email?` + params }; + } else if (factor === AuthenticationMethodType.U2F) { + return { redirect: `/u2f?` + params }; + } + } else if (availableMultiFactors?.length > 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/mfa?` + params }; + } else if ( + (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && + !availableMultiFactors.length + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "true", // this defines if the mfa is forced in the settings + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } else if ( + loginSettings?.mfaInitSkipLifetime && + (loginSettings.mfaInitSkipLifetime.nanos > 0 || + loginSettings.mfaInitSkipLifetime.seconds > 0) && + !availableMultiFactors.length && + session?.factors?.user?.id + ) { + const userResponse = await getUserByID({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + const humanUser = + userResponse?.user?.type.case === "human" + ? userResponse?.user.type.value + : undefined; + + if (humanUser?.mfaInitSkipped) { + const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped); + + const mfaInitSkipLifetimeMillis = + Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + + loginSettings.mfaInitSkipLifetime.nanos / 1000000; + const currentTime = Date.now(); + const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime(); + const timeDifference = currentTime - mfaInitSkippedTime; + + if (!(timeDifference > mfaInitSkipLifetimeMillis)) { + // if the time difference is smaller than the lifetime, skip the mfa setup + return; + } + } + + // the user has never skipped the mfa init but we have a setting so we redirect + + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "false", // this defines if the mfa is not forced in the settings and can be skipped + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } +} + +export async function checkUserVerification(userId: string): Promise { + // check if a verification was done earlier + const cookiesList = await cookies(); + + // only read cookie to prevent issues on page.tsx + const fingerPrintCookie = await getFingerprintIdCookie(); + + if (!fingerPrintCookie || !fingerPrintCookie.value) { + return false; + } + + const verificationCheck = crypto + .createHash("sha256") + .update(`${userId}:${fingerPrintCookie.value}`) + .digest("hex"); + + const cookieValue = await cookiesList.get("verificationCheck")?.value; + + if (!cookieValue) { + console.warn( + "User verification check cookie not found. User verification check failed.", + ); + return false; + } + + if (cookieValue !== verificationCheck) { + console.warn( + `User verification check failed. Expected ${verificationCheck} but got ${cookieValue}`, + ); + return false; + } + + return true; +} diff --git a/login/apps/login/src/lib/zitadel.ts b/login/apps/login/src/lib/zitadel.ts new file mode 100644 index 0000000000..8cc4efe0bc --- /dev/null +++ b/login/apps/login/src/lib/zitadel.ts @@ -0,0 +1,1415 @@ +import { Client, create, Duration } from "@zitadel/client"; +import { makeReqCtx } from "@zitadel/client/v2"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; +import { + OrganizationSchema, + TextQueryMethod, +} from "@zitadel/proto/zitadel/object/v2/object_pb"; +import { + CreateCallbackRequest, + OIDCService, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; +import { + CreateResponseRequest, + SAMLService, +} from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { + Checks, + SessionService, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; +import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; +import { + NotificationType, + SendPasswordResetLinkSchema, +} from "@zitadel/proto/zitadel/user/v2/password_pb"; +import { + SearchQuery, + SearchQuerySchema, +} from "@zitadel/proto/zitadel/user/v2/query_pb"; +import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + AddHumanUserRequest, + AddHumanUserRequestSchema, + ResendEmailCodeRequest, + ResendEmailCodeRequestSchema, + SendEmailCodeRequestSchema, + SetPasswordRequest, + SetPasswordRequestSchema, + UpdateHumanUserRequest, + UserService, + VerifyPasskeyRegistrationRequest, + VerifyU2FRegistrationRequest, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { unstable_cacheLife as cacheLife } from "next/cache"; +import { getUserAgent } from "./fingerprint"; +import { createServiceForHost } from "./service"; + +const useCache = process.env.DEBUG !== "true"; + +async function cacheWrapper(callback: Promise) { + "use cache"; + cacheLife("hours"); + + return callback; +} + +export async function getBrandingSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getBrandingSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLoginSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLoginSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getSecuritySettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getSecuritySettings({}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLockoutSettings({ + serviceUrl, + orgId, +}: { + serviceUrl: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLockoutSettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordExpirySettings({ + serviceUrl, + orgId, +}: { + serviceUrl: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function listIDPLinks({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listIDPLinks({ userId }, {}); +} + +export async function addOTPEmail({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addOTPEmail({ userId }, {}); +} + +export async function addOTPSMS({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addOTPSMS({ userId }, {}); +} + +export async function registerTOTP({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerTOTP({ userId }, {}); +} + +export async function getGeneralSettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getGeneralSettings({}, {}) + .then((resp) => resp.supportedLanguages); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLegalAndSupportSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordComplexitySettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getPasswordComplexitySettings({ ctx: makeReqCtx(organization) }) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function createSessionFromChecks({ + serviceUrl, + checks, + lifetime, +}: { + serviceUrl: string; + checks: Checks; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + const userAgent = await getUserAgent(); + + return sessionService.createSession({ checks, lifetime, userAgent }, {}); +} + +export async function createSessionForUserIdAndIdpIntent({ + serviceUrl, + userId, + idpIntent, + lifetime, +}: { + serviceUrl: string; + userId: string; + idpIntent: { + idpIntentId?: string | undefined; + idpIntentToken?: string | undefined; + }; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + const userAgent = await getUserAgent(); + + return sessionService.createSession({ + checks: { + user: { + search: { + case: "userId", + value: userId, + }, + }, + idpIntent, + }, + lifetime, + userAgent, + }); +} + +export async function setSession({ + serviceUrl, + sessionId, + sessionToken, + challenges, + checks, + lifetime, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; + challenges: RequestChallenges | undefined; + checks?: Checks; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.setSession( + { + sessionId, + sessionToken, + challenges, + checks: checks ? checks : {}, + metadata: {}, + lifetime, + }, + {}, + ); +} + +export async function getSession({ + serviceUrl, + sessionId, + sessionToken, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.getSession({ sessionId, sessionToken }, {}); +} + +export async function deleteSession({ + serviceUrl, + sessionId, + sessionToken, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.deleteSession({ sessionId, sessionToken }, {}); +} + +type ListSessionsCommand = { + serviceUrl: string; + ids: string[]; +}; + +export async function listSessions({ serviceUrl, ids }: ListSessionsCommand) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.listSessions( + { + queries: [ + { + query: { + case: "idsQuery", + value: { ids }, + }, + }, + ], + }, + {}, + ); +} + +export type AddHumanUserData = { + serviceUrl: string; + firstName: string; + lastName: string; + email: string; + password?: string; + organization: string; +}; + +export async function addHumanUser({ + serviceUrl, + email, + firstName, + lastName, + password, + organization, +}: AddHumanUserData) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + let addHumanUserRequest: AddHumanUserRequest = create( + AddHumanUserRequestSchema, + { + email: { + email, + verification: { + case: "isVerified", + value: false, + }, + }, + username: email, + profile: { givenName: firstName, familyName: lastName }, + passwordType: password + ? { case: "password", value: { password } } + : undefined, + }, + ); + + if (organization) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: organization }, + }); + + addHumanUserRequest = { + ...addHumanUserRequest, + organization: organizationSchema, + }; + } + + return userService.addHumanUser(addHumanUserRequest); +} + +export async function addHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: AddHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addHumanUser(request); +} + +export async function updateHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: UpdateHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.updateHumanUser(request); +} + +export async function verifyTOTPRegistration({ + serviceUrl, + code, + userId, +}: { + serviceUrl: string; + code: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyTOTPRegistration({ code, userId }, {}); +} + +export async function getUserByID({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.getUserByID({ userId }, {}); +} + +export async function humanMFAInitSkipped({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.humanMFAInitSkipped({ userId }, {}); +} + +export async function verifyInviteCode({ + serviceUrl, + userId, + verificationCode, +}: { + serviceUrl: string; + userId: string; + verificationCode: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyInviteCode({ userId, verificationCode }, {}); +} + +export async function sendEmailCode({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate: string; +}) { + let medium = create(SendEmailCodeRequestSchema, { userId }); + + medium = create(SendEmailCodeRequestSchema, { + ...medium, + verification: { + case: "sendCode", + value: create(SendEmailVerificationCodeSchema, { + urlTemplate, + }), + }, + }); + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.sendEmailCode(medium, {}); +} + +export async function createInviteCode({ + serviceUrl, + urlTemplate, + userId, +}: { + serviceUrl: string; + urlTemplate: string; + userId: string; +}) { + let medium = create(SendInviteCodeSchema, { + applicationName: "Typescript Login", + }); + + medium = { + ...medium, + urlTemplate, + }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.createInviteCode( + { + userId, + verification: { + case: "sendCode", + value: medium, + }, + }, + {}, + ); +} + +export type ListUsersCommand = { + serviceUrl: string; + loginName?: string; + userName?: string; + email?: string; + phone?: string; + organizationId?: string; +}; + +export async function listUsers({ + serviceUrl, + loginName, + userName, + phone, + email, + organizationId, +}: ListUsersCommand) { + const queries: SearchQuery[] = []; + + // either use loginName or userName, email, phone + if (loginName) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "loginNameQuery", + value: { + loginName, + method: TextQueryMethod.EQUALS, + }, + }, + }), + ); + } else if (userName || email || phone) { + const orQueries: SearchQuery[] = []; + + if (userName) { + const userNameQuery = create(SearchQuerySchema, { + query: { + case: "userNameQuery", + value: { + userName, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(userNameQuery); + } + + if (email) { + const emailQuery = create(SearchQuerySchema, { + query: { + case: "emailQuery", + value: { + emailAddress: email, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(emailQuery); + } + + if (phone) { + const phoneQuery = create(SearchQuerySchema, { + query: { + case: "phoneQuery", + value: { + number: phone, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(phoneQuery); + } + + queries.push( + create(SearchQuerySchema, { + query: { + case: "orQuery", + value: { + queries: orQueries, + }, + }, + }), + ); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listUsers({ queries }); +} + +export type SearchUsersCommand = { + serviceUrl: string; + searchValue: string; + loginSettings: LoginSettings; + organizationId?: string; + suffix?: string; +}; + +const PhoneQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "phoneQuery", + value: { + number: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +const LoginNameQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "loginNameQuery", + value: { + loginName: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +const EmailQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "emailQuery", + value: { + emailAddress: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +/** + * this is a dedicated search function to search for users from the loginname page + * it searches users based on the loginName or userName and org suffix combination, and falls back to email and phone if no users are found + * */ +export async function searchUsers({ + serviceUrl, + searchValue, + loginSettings, + organizationId, + suffix, +}: SearchUsersCommand) { + const queries: SearchQuery[] = []; + + // if a suffix is provided, we search for the userName concatenated with the suffix + if (suffix) { + const searchValueWithSuffix = `${searchValue}@${suffix}`; + const loginNameQuery = LoginNameQuery(searchValueWithSuffix); + queries.push(loginNameQuery); + } else { + const loginNameQuery = LoginNameQuery(searchValue); + queries.push(loginNameQuery); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + const loginNameResult = await userService.listUsers({ queries }); + + if (!loginNameResult || !loginNameResult.details) { + return { error: "An error occurred." }; + } + + if (loginNameResult.result.length > 1) { + return { error: "Multiple users found" }; + } + + if (loginNameResult.result.length == 1) { + return loginNameResult; + } + + const emailAndPhoneQueries: SearchQuery[] = []; + if ( + loginSettings.disableLoginWithEmail && + loginSettings.disableLoginWithPhone + ) { + return { error: "User not found in the system" }; + } else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) { + const phoneQuery = PhoneQuery(searchValue); + emailAndPhoneQueries.push(phoneQuery); + } else if (loginSettings.disableLoginWithPhone) { + const emailQuery = EmailQuery(searchValue); + emailAndPhoneQueries.push(emailQuery); + } else { + const emailAndPhoneOrQueries: SearchQuery[] = []; + + const emailQuery = EmailQuery(searchValue); + emailAndPhoneOrQueries.push(emailQuery); + + let phoneQuery; + if (searchValue.length <= 20) { + phoneQuery = PhoneQuery(searchValue); + emailAndPhoneOrQueries.push(phoneQuery); + } + + emailAndPhoneQueries.push( + create(SearchQuerySchema, { + query: { + case: "orQuery", + value: { + queries: emailAndPhoneOrQueries, + }, + }, + }), + ); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const emailOrPhoneResult = await userService.listUsers({ + queries: emailAndPhoneQueries, + }); + + if (!emailOrPhoneResult || !emailOrPhoneResult.details) { + return { error: "An error occurred." }; + } + + if (emailOrPhoneResult.result.length > 1) { + return { error: "Multiple users found." }; + } + + if (emailOrPhoneResult.result.length == 1) { + return loginNameResult; + } + + return { error: "User not found in the system" }; +} + +export async function getDefaultOrg({ + serviceUrl, +}: { + serviceUrl: string; +}): Promise { + const orgService: Client = + await createServiceForHost(OrganizationService, serviceUrl); + + return orgService + .listOrganizations( + { + queries: [ + { + query: { + case: "defaultQuery", + value: {}, + }, + }, + ], + }, + {}, + ) + .then((resp) => (resp?.result && resp.result[0] ? resp.result[0] : null)); +} + +export async function getOrgsByDomain({ + serviceUrl, + domain, +}: { + serviceUrl: string; + domain: string; +}) { + const orgService: Client = + await createServiceForHost(OrganizationService, serviceUrl); + + return orgService.listOrganizations( + { + queries: [ + { + query: { + case: "domainQuery", + value: { domain, method: TextQueryMethod.EQUALS }, + }, + }, + ], + }, + {}, + ); +} + +export async function startIdentityProviderFlow({ + serviceUrl, + idpId, + urls, +}: { + serviceUrl: string; + + idpId: string; + urls: RedirectURLsJson; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.startIdentityProviderIntent({ + idpId, + content: { + case: "urls", + value: urls, + }, + }); +} + +export async function getAuthRequest({ + serviceUrl, + authRequestId, +}: { + serviceUrl: string; + authRequestId: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getAuthRequest({ + authRequestId, + }); +} + +export async function getDeviceAuthorizationRequest({ + serviceUrl, + userCode, +}: { + serviceUrl: string; + userCode: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getDeviceAuthorizationRequest({ + userCode, + }); +} + +export async function authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, +}: { + serviceUrl: string; + deviceAuthorizationId: string; + session?: { sessionId: string; sessionToken: string }; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.authorizeOrDenyDeviceAuthorization({ + deviceAuthorizationId, + decision: session + ? { + case: "session", + value: session, + } + : { + case: "deny", + value: {}, + }, + }); +} + +export async function createCallback({ + serviceUrl, + req, +}: { + serviceUrl: string; + req: CreateCallbackRequest; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.createCallback(req); +} + +export async function getSAMLRequest({ + serviceUrl, + samlRequestId, +}: { + serviceUrl: string; + samlRequestId: string; +}) { + const samlService = await createServiceForHost(SAMLService, serviceUrl); + + return samlService.getSAMLRequest({ + samlRequestId, + }); +} + +export async function createResponse({ + serviceUrl, + req, +}: { + serviceUrl: string; + req: CreateResponseRequest; +}) { + const samlService = await createServiceForHost(SAMLService, serviceUrl); + + return samlService.createResponse(req); +} + +export async function verifyEmail({ + serviceUrl, + userId, + verificationCode, +}: { + serviceUrl: string; + userId: string; + verificationCode: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyEmail( + { + userId, + verificationCode, + }, + {}, + ); +} + +export async function resendEmailCode({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate: string; +}) { + let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, { + userId, + }); + + const medium = create(SendEmailVerificationCodeSchema, { + urlTemplate, + }); + + request = { ...request, verification: { case: "sendCode", value: medium } }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.resendEmailCode(request, {}); +} + +export async function retrieveIDPIntent({ + serviceUrl, + id, + token, +}: { + serviceUrl: string; + id: string; + token: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.retrieveIdentityProviderIntent( + { idpIntentId: id, idpIntentToken: token }, + {}, + ); +} + +export async function getIDPByID({ + serviceUrl, + id, +}: { + serviceUrl: string; + id: string; +}) { + const idpService: Client = + await createServiceForHost(IdentityProviderService, serviceUrl); + + return idpService.getIDPByID({ id }, {}).then((resp) => resp.idp); +} + +export async function addIDPLink({ + serviceUrl, + idp, + userId, +}: { + serviceUrl: string; + idp: { id: string; userId: string; userName: string }; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addIDPLink( + { + idpLink: { + userId: idp.userId, + idpId: idp.id, + userName: idp.userName, + }, + userId, + }, + {}, + ); +} + +export async function passwordReset({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate?: string; +}) { + let medium = create(SendPasswordResetLinkSchema, { + notificationType: NotificationType.Email, + }); + + medium = { + ...medium, + urlTemplate, + }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.passwordReset( + { + userId, + medium: { + case: "sendLink", + value: medium, + }, + }, + {}, + ); +} + +export async function setUserPassword({ + serviceUrl, + userId, + password, + code, +}: { + serviceUrl: string; + userId: string; + password: string; + code?: string; +}) { + let payload = create(SetPasswordRequestSchema, { + userId, + newPassword: { + password, + }, + }); + + if (code) { + payload = { + ...payload, + verification: { + case: "verificationCode", + value: code, + }, + }; + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.setPassword(payload, {}).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: error.message }; + } else { + throw error; + } + }); +} + +export async function setPassword({ + serviceUrl, + payload, +}: { + serviceUrl: string; + payload: SetPasswordRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.setPassword(payload, {}); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @returns the newly set email + */ +export async function createPasskeyRegistrationLink({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.createPasskeyRegistrationLink({ + userId, + medium: { + case: "returnCode", + value: {}, + }, + }); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @param domain the domain on which the factor is registered + * @returns the newly set email + */ +export async function registerU2F({ + serviceUrl, + userId, + domain, +}: { + serviceUrl: string; + userId: string; + domain: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerU2F({ + userId, + domain, + }); +} + +/** + * + * @param host + * @param request the request object for verifying U2F registration + * @returns the result of the verification + */ +export async function verifyU2FRegistration({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: VerifyU2FRegistrationRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyU2FRegistration(request, {}); +} + +/** + * + * @param host + * @param orgId the organization ID + * @param linking_allowed whether linking is allowed + * @returns the active identity providers + */ +export async function getActiveIdentityProviders({ + serviceUrl, + orgId, + linking_allowed, +}: { + serviceUrl: string; + orgId?: string; + linking_allowed?: boolean; +}) { + const props: any = { ctx: makeReqCtx(orgId) }; + if (linking_allowed) { + props.linkingAllowed = linking_allowed; + } + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + return settingsService.getActiveIdentityProviders(props, {}); +} + +/** + * + * @param host + * @param request the request object for verifying passkey registration + * @returns the result of the verification + */ +export async function verifyPasskeyRegistration({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: VerifyPasskeyRegistrationRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyPasskeyRegistration(request, {}); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @param code the code for registering the passkey + * @param domain the domain on which the factor is registered + * @returns the newly set email + */ +export async function registerPasskey({ + serviceUrl, + userId, + code, + domain, +}: { + serviceUrl: string; + userId: string; + code: { id: string; code: string }; + domain: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerPasskey({ + userId, + code, + domain, + }); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @returns the list of authentication method types + */ +export async function listAuthenticationMethodTypes({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listAuthenticationMethodTypes({ + userId, + }); +} diff --git a/login/apps/login/src/middleware.ts b/login/apps/login/src/middleware.ts new file mode 100644 index 0000000000..4d66d0ab39 --- /dev/null +++ b/login/apps/login/src/middleware.ts @@ -0,0 +1,74 @@ +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../constants/csp"; +import { getServiceUrlFromHeaders } from "./lib/service-url"; + +export const config = { + matcher: [ + "/.well-known/:path*", + "/oauth/:path*", + "/oidc/:path*", + "/idps/callback/:path*", + "/saml/:path*", + ], +}; + +export async function middleware(request: NextRequest) { + // escape proxy if the environment is setup for multitenancy + if (!process.env.ZITADEL_API_URL || !process.env.ZITADEL_SERVICE_USER_TOKEN) { + return NextResponse.next(); + } + + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // Call the /security route handler + // TODO check this on cloud run deployment + const securityResponse = await fetch(`${request.nextUrl.origin}/security`); + + if (!securityResponse.ok) { + console.error( + "Failed to fetch security settings:", + securityResponse.statusText, + ); + return NextResponse.next(); // Fallback if the request fails + } + + const { settings: securitySettings } = await securityResponse.json(); + + const instanceHost = `${serviceUrl}` + .replace("https://", "") + .replace("http://", ""); + + const requestHeaders = new Headers(request.headers); + + // this is a workaround for the next.js server not forwarding the host header + // requestHeaders.set("x-zitadel-forwarded", `host="${request.nextUrl.host}"`); + requestHeaders.set("x-zitadel-public-host", `${request.nextUrl.host}`); + + // this is a workaround for the next.js server not forwarding the host header + requestHeaders.set("x-zitadel-instance-host", instanceHost); + + const responseHeaders = new Headers(); + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Headers", "*"); + + if (securitySettings?.embeddedIframe?.enabled) { + securitySettings.embeddedIframe.allowedOrigins; + responseHeaders.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + responseHeaders.delete("X-Frame-Options"); + } + + request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; + + return NextResponse.rewrite(request.nextUrl, { + request: { + headers: requestHeaders, + }, + headers: responseHeaders, + }); +} diff --git a/login/apps/login/src/styles/globals.scss b/login/apps/login/src/styles/globals.scss new file mode 100755 index 0000000000..cfce853bc7 --- /dev/null +++ b/login/apps/login/src/styles/globals.scss @@ -0,0 +1,65 @@ +// include styles from the ui package +@use "./vars.scss"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1, + .ztdl-h1 { + @apply text-2xl text-center; + } + + .ztdl-p { + @apply text-sm text-center text-text-light-secondary-500 dark:text-text-dark-secondary-500 text-center; + } +} + +html { + --background-color: #ffffff; + --dark-background-color: #000000; +} + +.form-checkbox:checked { + background-image: url("/checkbox.svg"); +} + +.skeleton { + --accents-2: var(--theme-light-background-400); + --accents-1: var(--theme-light-background-500); + + background-image: linear-gradient( + 270deg, + var(--accents-1), + var(--accents-2), + var(--accents-2), + var(--accents-1) + ); + background-size: 400% 100%; + animation: skeleton_loading 8s ease-in-out infinite; +} + +.dark .skeleton { + --accents-2: var(--theme-dark-background-400); + --accents-1: var(--theme-dark-background-500); + + background-image: linear-gradient( + 270deg, + var(--accents-1), + var(--accents-2), + var(--accents-2), + var(--accents-1) + ); + background-size: 400% 100%; + animation: skeleton_loading 8s ease-in-out infinite; +} + +@keyframes skeleton_loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/login/apps/login/src/styles/vars.scss b/login/apps/login/src/styles/vars.scss new file mode 100644 index 0000000000..71c6a28782 --- /dev/null +++ b/login/apps/login/src/styles/vars.scss @@ -0,0 +1,174 @@ +:root { + --theme-dark-primary-50: #f1f7fd; + --theme-dark-primary-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-100: #afd1f2; + --theme-dark-primary-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-200: #7fb5ea; + --theme-dark-primary-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-300: #4192e0; + --theme-dark-primary-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-400: #2782dc; + --theme-dark-primary-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-500: #2073c4; + --theme-dark-primary-contrast-500: #ffffff; + --theme-dark-primary-600: #1c64aa; + --theme-dark-primary-contrast-600: #ffffff; + --theme-dark-primary-700: #17548f; + --theme-dark-primary-contrast-700: #ffffff; + --theme-dark-primary-800: #134575; + --theme-dark-primary-contrast-800: #ffffff; + --theme-dark-primary-900: #0f355b; + --theme-dark-primary-contrast-900: #ffffff; + --theme-dark-primary-A100: #e4f2ff; + --theme-dark-primary-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A200: #7ebfff; + --theme-dark-primary-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A400: #278df0; + --theme-dark-primary-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A700: #1d80e0; + --theme-dark-primary-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-50: #ffffff; + --theme-light-primary-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-100: #ebedfa; + --theme-light-primary-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-200: #bec6ef; + --theme-light-primary-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-300: #8594e0; + --theme-light-primary-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-400: #6c7eda; + --theme-light-primary-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-500: #5469d4; + --theme-light-primary-contrast-500: #ffffff; + --theme-light-primary-600: #3c54ce; + --theme-light-primary-contrast-600: #ffffff; + --theme-light-primary-700: #2f46bc; + --theme-light-primary-contrast-700: #ffffff; + --theme-light-primary-800: #293da3; + --theme-light-primary-contrast-800: #ffffff; + --theme-light-primary-900: #23348b; + --theme-light-primary-contrast-900: #ffffff; + --theme-light-primary-A100: #ffffff; + --theme-light-primary-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A200: #c5cefc; + --theme-light-primary-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A400: #7085ea; + --theme-light-primary-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A700: #6478de; + --theme-light-primary-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-50: #ffffff; + --theme-dark-warn-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-100: #fff8f9; + --theme-dark-warn-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-200: #ffc0ca; + --theme-dark-warn-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-300: #ff788e; + --theme-dark-warn-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-400: #ff5a75; + --theme-dark-warn-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-500: #ff3b5b; + --theme-dark-warn-contrast-500: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-600: #ff1c41; + --theme-dark-warn-contrast-600: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-700: #fd0029; + --theme-dark-warn-contrast-700: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-800: #de0024; + --theme-dark-warn-contrast-800: #ffffff; + --theme-dark-warn-900: #c0001f; + --theme-dark-warn-contrast-900: #ffffff; + --theme-dark-warn-A100: #ffffff; + --theme-dark-warn-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A200: #ffd4db; + --theme-dark-warn-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A400: #ff6e86; + --theme-dark-warn-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A700: #ff5470; + --theme-dark-warn-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-50: #ffffff; + --theme-light-warn-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-100: #f4d3d9; + --theme-light-warn-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-200: #e8a6b2; + --theme-light-warn-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-300: #da6e80; + --theme-light-warn-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-400: #d3556b; + --theme-light-warn-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-500: #cd3d56; + --theme-light-warn-contrast-500: #ffffff; + --theme-light-warn-600: #bb3048; + --theme-light-warn-contrast-600: #ffffff; + --theme-light-warn-700: #a32a3f; + --theme-light-warn-contrast-700: #ffffff; + --theme-light-warn-800: #8a2436; + --theme-light-warn-contrast-800: #ffffff; + --theme-light-warn-900: #721d2c; + --theme-light-warn-contrast-900: #ffffff; + --theme-light-warn-A100: #ffffff; + --theme-light-warn-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A200: #faa9b7; + --theme-light-warn-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A400: #e65770; + --theme-light-warn-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A700: #d84c64; + --theme-light-warn-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-50: #7c93c6; + --theme-dark-background-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-100: #4a69aa; + --theme-dark-background-contrast-100: #ffffff; + --theme-dark-background-200: #395183; + --theme-dark-background-contrast-200: #ffffff; + --theme-dark-background-300: #243252; + --theme-dark-background-contrast-300: #ffffff; + --theme-dark-background-400: #1a253c; + --theme-dark-background-contrast-400: #ffffff; + --theme-dark-background-500: #111827; + --theme-dark-background-contrast-500: #ffffff; + --theme-dark-background-600: #080b12; + --theme-dark-background-contrast-600: #ffffff; + --theme-dark-background-700: #000000; + --theme-dark-background-contrast-700: #ffffff; + --theme-dark-background-800: #000000; + --theme-dark-background-contrast-800: #ffffff; + --theme-dark-background-900: #000000; + --theme-dark-background-contrast-900: #ffffff; + --theme-dark-background-A100: #5782e0; + --theme-dark-background-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-A200: #204eb1; + --theme-dark-background-contrast-A200: #ffffff; + --theme-dark-background-A400: #182b53; + --theme-dark-background-contrast-A400: #ffffff; + --theme-dark-background-A700: #17223b; + --theme-dark-background-contrast-A700: #ffffff; + --theme-light-background-50: #ffffff; + --theme-light-background-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-background-100: #ffffff; + --theme-light-background-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-background-200: #ffffff; + --theme-light-background-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-background-300: #ffffff; + --theme-light-background-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-background-400: #ffffff; + --theme-light-background-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-background-500: #fafafa; + --theme-light-background-contrast-500: hsla(0, 0%, 0%, 0.87); + --theme-light-background-600: #ebebeb; + --theme-light-background-contrast-600: hsla(0, 0%, 0%, 0.87); + --theme-light-background-700: #dbdbdb; + --theme-light-background-contrast-700: hsla(0, 0%, 0%, 0.87); + --theme-light-background-800: #cccccc; + --theme-light-background-contrast-800: hsla(0, 0%, 0%, 0.87); + --theme-light-background-900: #bdbdbd; + --theme-light-background-contrast-900: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A100: #ffffff; + --theme-light-background-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A200: #ffffff; + --theme-light-background-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A400: #ffffff; + --theme-light-background-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A700: #ffffff; + --theme-light-background-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-text: #ffffff; + --theme-dark-secondary-text: #ffffffc7; + --theme-light-text: #000000; + --theme-light-secondary-text: #000000c7; +} diff --git a/login/apps/login/tailwind.config.mjs b/login/apps/login/tailwind.config.mjs new file mode 100644 index 0000000000..908068e5dd --- /dev/null +++ b/login/apps/login/tailwind.config.mjs @@ -0,0 +1,117 @@ +import sharedConfig from "@zitadel/tailwind-config/tailwind.config.mjs"; + +let colors = { + background: { light: { contrast: {} }, dark: { contrast: {} } }, + primary: { light: { contrast: {} }, dark: { contrast: {} } }, + warn: { light: { contrast: {} }, dark: { contrast: {} } }, + text: { light: { contrast: {} }, dark: { contrast: {} } }, + link: { light: { contrast: {} }, dark: { contrast: {} } }, +}; + +const shades = [ + "50", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +]; +const themes = ["light", "dark"]; +const types = ["background", "primary", "warn", "text", "link"]; +types.forEach((type) => { + themes.forEach((theme) => { + shades.forEach((shade) => { + colors[type][theme][shade] = `var(--theme-${theme}-${type}-${shade})`; + colors[type][theme][`contrast-${shade}`] = + `var(--theme-${theme}-${type}-contrast-${shade})`; + colors[type][theme][`secondary-${shade}`] = + `var(--theme-${theme}-${type}-secondary-${shade})`; + }); + }); +}); + +/** @type {import('tailwindcss').Config} */ +export default { + presets: [sharedConfig], + darkMode: "class", + content: ["./src/**/*.{js,ts,jsx,tsx}"], + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + extend: { + colors: { + ...colors, + state: { + success: { + light: { + background: "#cbf4c9", + color: "#0e6245", + }, + dark: { + background: "#68cf8340", + color: "#cbf4c9", + }, + }, + error: { + light: { + background: "#ffc1c1", + color: "#620e0e", + }, + dark: { + background: "#af455359", + color: "#ffc1c1", + }, + }, + neutral: { + light: { + background: "#e4e7e4", + color: "#000000", + }, + dark: { + background: "#1a253c", + color: "#ffffff", + }, + }, + alert: { + light: { + background: "#fbbf24", + color: "#92400e", + }, + dark: { + background: "#92400e50", + color: "#fbbf24", + }, + }, + }, + }, + animation: { + shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;", + }, + keyframes: { + shake: { + "10%, 90%": { + transform: "translate3d(-1px, 0, 0)", + }, + + "20%, 80%": { + transform: "translate3d(2px, 0, 0)", + }, + + "30%, 50%, 70%": { + transform: "translate3d(-4px, 0, 0)", + }, + + "40%, 60%": { + transform: "translate3d(4px, 0, 0)", + }, + }, + }, + }, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/login/apps/login/tsconfig.json b/login/apps/login/tsconfig.json new file mode 100755 index 0000000000..c855c43225 --- /dev/null +++ b/login/apps/login/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@zitadel/tsconfig/nextjs.json", + "compilerOptions": { + "jsx": "preserve", + "target": "es2022", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "custom-config.js" + ], + "exclude": ["node_modules"] +} diff --git a/login/apps/login/turbo.json b/login/apps/login/turbo.json new file mode 100644 index 0000000000..80224125a2 --- /dev/null +++ b/login/apps/login/turbo.json @@ -0,0 +1,25 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "dependsOn": ["^build"] + }, + "build:standalone": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:integration": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:unit": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:watch": { + "dependsOn": ["@zitadel/client#build"] + } + } +} diff --git a/login/apps/login/vitest.config.mts b/login/apps/login/vitest.config.mts new file mode 100644 index 0000000000..238c5b8b93 --- /dev/null +++ b/login/apps/login/vitest.config.mts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + environment: "jsdom", + setupFiles: ["@testing-library/jest-dom/vitest"], + }, +}); diff --git a/login/meta.json b/login/meta.json new file mode 100644 index 0000000000..2c443f76cf --- /dev/null +++ b/login/meta.json @@ -0,0 +1,4 @@ +{ + "name": "ZITADEL typescript Monorepo with Changesets", + "description": "ZITADEL typescript monorepo preconfigured to publish packages via Changesets" +} diff --git a/login/package.json b/login/package.json new file mode 100644 index 0000000000..c96663b83d --- /dev/null +++ b/login/package.json @@ -0,0 +1,62 @@ +{ + "packageManager": "pnpm@9.1.2+sha256.19c17528f9ca20bd442e4ca42f00f1b9808a9cb419383cd04ba32ef19322aba7", + "private": true, + "name": "typescript-monorepo", + "scripts": { + "generate": "turbo run generate", + "build": "turbo run build", + "build:docker": "rm -rf ./out ./docker && turbo run build --filter=./packages/zitadel-client && turbo prune @zitadel/login --docker && mkdir -p ./docker && cd ./docker && cp -r ../out/json/* . && pnpm install --frozen-lockfile && cp -r ../out/full/* . && turbo run build:standalone && cd ..", + "build:packages": "turbo run build --filter=./packages/*", + "build:apps": "turbo run build --filter=./apps/*", + "test": "turbo run test", + "start": "turbo run start", + "start:built": "turbo run start:built", + "test:unit": "turbo run test:unit -- --passWithNoTests", + "test:integration": "turbo run test:integration", + "test:acceptance": "pnpm exec playwright test", + "test:watch": "turbo run test:watch", + "dev": "turbo run dev --no-cache --continue", + "lint": "turbo run lint", + "lint:fix": "turbo run lint:fix", + "clean": "turbo run clean && rm -rf node_modules", + "format:fix": "prettier --write \"**/*.{ts,tsx,md}\"", + "format": "prettier --check \"**/*.{ts,tsx,md}\"", + "changeset": "changeset", + "version-packages": "changeset version", + "release": "turbo run build --filter=login^... && changeset publish", + "run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup", + "run-sink": "docker compose -f ./acceptance/docker-compose.yaml up -d sink", + "run-samlsp": "docker compose -f ./acceptance/saml/docker-compose.yaml up -d", + "run-samlidp": "docker compose -f ./acceptance/idp/saml/docker-compose.yaml up -d", + "run-oidcrp": "docker compose -f ./acceptance/oidc/docker-compose.yaml up -d", + "run-oidcop": "docker compose -f ./acceptance/idp/oidc/docker-compose.yaml up -d", + "stop": "docker compose -f ./acceptance/docker-compose.yaml stop" + }, + "pnpm": { + "overrides": { + "@typescript-eslint/parser": "^7.9.0" + } + }, + "devDependencies": { + "@otplib/core": "^12.0.0", + "@otplib/plugin-thirty-two": "^12.0.0", + "@otplib/plugin-crypto": "^12.0.0", + "@faker-js/faker": "^9.7.0", + "@changesets/cli": "^2.29.2", + "@playwright/test": "^1.52.0", + "@types/node": "^22.14.1", + "@vitejs/plugin-react": "^4.4.1", + "@zitadel/prettier-config": "workspace:*", + "axios": "^1.8.4", + "dotenv": "^16.5.0", + "eslint": "8.57.1", + "@zitadel/eslint-config": "workspace:*", + "prettier": "^3.5.3", + "prettier-plugin-organize-imports": "^4.1.0", + "tsup": "^8.4.0", + "turbo": "2.5.0", + "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.2" + } +} diff --git a/login/packages/zitadel-client/.eslintrc.cjs b/login/packages/zitadel-client/.eslintrc.cjs new file mode 100644 index 0000000000..0eb32ca20d --- /dev/null +++ b/login/packages/zitadel-client/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@zitadel/eslint-config"], +}; diff --git a/login/packages/zitadel-client/.gitignore b/login/packages/zitadel-client/.gitignore new file mode 100644 index 0000000000..c1aa2a7eb8 --- /dev/null +++ b/login/packages/zitadel-client/.gitignore @@ -0,0 +1 @@ +src/proto \ No newline at end of file diff --git a/login/packages/zitadel-client/CHANGELOG.md b/login/packages/zitadel-client/CHANGELOG.md new file mode 100644 index 0000000000..f1107bcc5a --- /dev/null +++ b/login/packages/zitadel-client/CHANGELOG.md @@ -0,0 +1,77 @@ +# @zitadel/client + +## 1.2.0 + +### Minor Changes + +- 62ad388: revert CJS support + +## 1.1.0 + +### Minor Changes + +- 9692297: add CJS and ESM support + +## 1.0.7 + +### Patch Changes + +- Updated dependencies [97b0332] + - @zitadel/proto@1.0.4 + +## 1.0.6 + +### Patch Changes + +- 90fbdd1: use node16/nodenext module resolution +- Updated dependencies [90fbdd1] + - @zitadel/proto@1.0.3 + +## 1.0.5 + +### Patch Changes + +- 4fa22c0: fix export for grpcweb transport + +## 1.0.4 + +### Patch Changes + +- 28dc956: dynamic properties for system token utility + +## 1.0.3 + +### Patch Changes + +- ef1c801: add missing client transport utility + +## 1.0.2 + +### Patch Changes + +- Updated dependencies + - @zitadel/proto@1.0.2 + +## 1.0.1 + +### Patch Changes + +- README updates +- Updated dependencies + - @zitadel/proto@1.0.1 + +## 1.0.0 + +### Major Changes + +- 32e1199: Initial Release + +### Minor Changes + +- f32ab7f: Initial release + +### Patch Changes + +- Updated dependencies [f32ab7f] +- Updated dependencies [32e1199] + - @zitadel/proto@1.0.0 diff --git a/login/packages/zitadel-client/README.md b/login/packages/zitadel-client/README.md new file mode 100644 index 0000000000..0a14fe32f5 --- /dev/null +++ b/login/packages/zitadel-client/README.md @@ -0,0 +1,53 @@ +# ZITADEL Client + +This package exports services and utilities to interact with ZITADEL + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/client +``` + +or + +```sh +yarn add @zitadel/client +``` + +## Usage + +### Importing Services + +You can import and use the services provided by this package to interact with ZITADEL. + +```ts +import { createSettingsServiceClient, makeReqCtx } from "@zitadel/client/v2"; + +// Example usage +const transport = createServerTransport(process.env.ZITADEL_SERVICE_USER_TOKEN!, { baseUrl: process.env.ZITADEL_API_URL! }); + +const settingsService = createSettingsServiceClient(transport); + +settingsService.getBrandingSettings({ ctx: makeReqCtx("orgId") }, {}); +``` + +### Utilities + +This package also provides various utilities to work with ZITADEL + +```ts +import { timestampMs } from "@zitadel/client"; + +// Example usage +console.log(`${timestampMs(session.creationDate)}`); +``` + +## Documentation + +For detailed documentation and API references, please visit the [ZITADEL documentation](https://zitadel.com/docs). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-client/package.json b/login/packages/zitadel-client/package.json new file mode 100644 index 0000000000..a47e3e56e9 --- /dev/null +++ b/login/packages/zitadel-client/package.json @@ -0,0 +1,68 @@ +{ + "name": "@zitadel/client", + "version": "1.2.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./v1": { + "types": "./dist/v1.d.ts", + "import": "./dist/v1.js", + "require": "./dist/v1.cjs" + }, + "./v2": { + "types": "./dist/v2.d.ts", + "import": "./dist/v2.js", + "require": "./dist/v2.cjs" + }, + "./v3alpha": { + "types": "./dist/v3alpha.d.ts", + "import": "./dist/v3alpha.js", + "require": "./dist/v3alpha.cjs" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js", + "require": "./dist/node.cjs" + }, + "./web": { + "types": "./dist/web.d.ts", + "import": "./dist/web.js", + "require": "./dist/web.cjs" + } + }, + "files": [ + "dist/**" + ], + "sideEffects": false, + "scripts": { + "build": "tsup", + "test": "pnpm test:unit", + "test:watch": "pnpm test:unit:watch", + "test:unit": "vitest", + "test:unit:watch": "vitest --watch", + "dev": "tsup --watch --dts", + "lint": "eslint \"src/**/*.ts*\"", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.2", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0", + "@connectrpc/connect-web": "^2.0.0", + "jose": "^5.3.0", + "@zitadel/proto": "workspace:*" + }, + "devDependencies": { + "@bufbuild/protocompile": "^0.0.1", + "@zitadel/tsconfig": "workspace:*", + "@zitadel/eslint-config": "workspace:*" + } +} diff --git a/login/packages/zitadel-client/src/helpers.ts b/login/packages/zitadel-client/src/helpers.ts new file mode 100644 index 0000000000..637cadf538 --- /dev/null +++ b/login/packages/zitadel-client/src/helpers.ts @@ -0,0 +1,11 @@ +import type { DescService } from "@bufbuild/protobuf"; +import { Timestamp, timestampDate } from "@bufbuild/protobuf/wkt"; +import { createClient, Transport } from "@connectrpc/connect"; + +export function createClientFor(service: TService) { + return (transport: Transport) => createClient(service, transport); +} + +export function toDate(timestamp: Timestamp | undefined): Date | undefined { + return timestamp ? timestampDate(timestamp) : undefined; +} diff --git a/login/packages/zitadel-client/src/index.ts b/login/packages/zitadel-client/src/index.ts new file mode 100644 index 0000000000..f3f93e593f --- /dev/null +++ b/login/packages/zitadel-client/src/index.ts @@ -0,0 +1,10 @@ +export { createClientFor, toDate } from "./helpers.js"; +export { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +// TODO: Move this to `./protobuf.ts` and export it from there +export { create, fromJson, toJson } from "@bufbuild/protobuf"; +export type { JsonObject } from "@bufbuild/protobuf"; +export type { GenService } from "@bufbuild/protobuf/codegenv1"; +export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; +export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; +export type { Client, Code, ConnectError } from "@connectrpc/connect"; diff --git a/login/packages/zitadel-client/src/interceptors.test.ts b/login/packages/zitadel-client/src/interceptors.test.ts new file mode 100644 index 0000000000..a5100f866a --- /dev/null +++ b/login/packages/zitadel-client/src/interceptors.test.ts @@ -0,0 +1,67 @@ +import { Int32Value } from "@bufbuild/protobuf/wkt"; +import { compileService } from "@bufbuild/protocompile"; +import { createRouterTransport, HandlerContext } from "@connectrpc/connect"; +import { describe, expect, test, vitest } from "vitest"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +const TestService = compileService(` + syntax = "proto3"; + package handwritten; + service TestService { + rpc Unary(Int32Value) returns (StringValue); + } + message Int32Value { + int32 value = 1; + } + message StringValue { + string value = 1; + } +`); + +describe("NewAuthorizationBearerInterceptor", () => { + const transport = { + interceptors: [NewAuthorizationBearerInterceptor("mytoken")], + }; + + test("injects the authorization token", async () => { + const handler = vitest.fn((request: Int32Value, context: HandlerContext) => { + return { value: request.value.toString() }; + }); + + const service = createRouterTransport( + ({ rpc }) => { + rpc(TestService.method.unary, handler); + }, + { transport }, + ); + + await service.unary(TestService.method.unary, undefined, undefined, {}, { value: 9001 }); + + expect(handler).toBeCalled(); + expect(handler.mock.calls[0][1].requestHeader.get("Authorization")).toBe("Bearer mytoken"); + }); + + test("do not overwrite the previous authorization token", async () => { + const handler = vitest.fn((request: Int32Value, context: HandlerContext) => { + return { value: request.value.toString() }; + }); + + const service = createRouterTransport( + ({ rpc }) => { + rpc(TestService.method.unary, handler); + }, + { transport }, + ); + + await service.unary( + TestService.method.unary, + undefined, + undefined, + { Authorization: "Bearer somethingelse" }, + { value: 9001 }, + ); + + expect(handler).toBeCalled(); + expect(handler.mock.calls[0][1].requestHeader.get("Authorization")).toBe("Bearer somethingelse"); + }); +}); diff --git a/login/packages/zitadel-client/src/interceptors.ts b/login/packages/zitadel-client/src/interceptors.ts new file mode 100644 index 0000000000..9d719c7d70 --- /dev/null +++ b/login/packages/zitadel-client/src/interceptors.ts @@ -0,0 +1,16 @@ +import type { Interceptor } from "@connectrpc/connect"; + +/** + * Creates an interceptor that adds an Authorization header with a Bearer token. + * @param token + */ +export function NewAuthorizationBearerInterceptor(token: string): Interceptor { + return (next) => (req) => { + // TODO: I am not what is the intent of checking for the Authorization header + // and setting it if it is not present. + if (!req.header.get("Authorization")) { + req.header.set("Authorization", `Bearer ${token}`); + } + return next(req); + }; +} diff --git a/login/packages/zitadel-client/src/node.ts b/login/packages/zitadel-client/src/node.ts new file mode 100644 index 0000000000..0b15310d2c --- /dev/null +++ b/login/packages/zitadel-client/src/node.ts @@ -0,0 +1,36 @@ +import { createGrpcTransport, GrpcTransportOptions } from "@connectrpc/connect-node"; +import { importPKCS8, SignJWT } from "jose"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +/** + * Create a server transport using grpc with the given token and configuration options. + * @param token + * @param opts + */ +export function createServerTransport(token: string, opts: GrpcTransportOptions) { + return createGrpcTransport({ + ...opts, + interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + }); +} + +export async function newSystemToken({ + audience, + subject, + key, + expirationTime, +}: { + audience: string; + subject: string; + key: string; + expirationTime?: number | string | Date; +}) { + return await new SignJWT({}) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime(expirationTime ?? "1h") + .setIssuer(subject) + .setSubject(subject) + .setAudience(audience) + .sign(await importPKCS8(key, "RS256")); +} diff --git a/login/packages/zitadel-client/src/v1.ts b/login/packages/zitadel-client/src/v1.ts new file mode 100644 index 0000000000..d04180cf88 --- /dev/null +++ b/login/packages/zitadel-client/src/v1.ts @@ -0,0 +1,11 @@ +import { createClientFor } from "./helpers.js"; + +import { AdminService } from "@zitadel/proto/zitadel/admin_pb.js"; +import { AuthService } from "@zitadel/proto/zitadel/auth_pb.js"; +import { ManagementService } from "@zitadel/proto/zitadel/management_pb.js"; +import { SystemService } from "@zitadel/proto/zitadel/system_pb.js"; + +export const createAdminServiceClient = createClientFor(AdminService); +export const createAuthServiceClient = createClientFor(AuthService); +export const createManagementServiceClient = createClientFor(ManagementService); +export const createSystemServiceClient = createClientFor(SystemService); diff --git a/login/packages/zitadel-client/src/v2.ts b/login/packages/zitadel-client/src/v2.ts new file mode 100644 index 0000000000..49cf901734 --- /dev/null +++ b/login/packages/zitadel-client/src/v2.ts @@ -0,0 +1,27 @@ +import { create } from "@bufbuild/protobuf"; +import { FeatureService } from "@zitadel/proto/zitadel/feature/v2/feature_service_pb.js"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb.js"; +import { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb.js"; +import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb.js"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb.js"; +import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb.js"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb.js"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb.js"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb.js"; + +import { createClientFor } from "./helpers.js"; + +export const createUserServiceClient = createClientFor(UserService); +export const createSettingsServiceClient = createClientFor(SettingsService); +export const createSessionServiceClient = createClientFor(SessionService); +export const createOIDCServiceClient = createClientFor(OIDCService); +export const createSAMLServiceClient = createClientFor(SAMLService); +export const createOrganizationServiceClient = createClientFor(OrganizationService); +export const createFeatureServiceClient = createClientFor(FeatureService); +export const createIdpServiceClient = createClientFor(IdentityProviderService); + +export function makeReqCtx(orgId: string | undefined) { + return create(RequestContextSchema, { + resourceOwner: orgId ? { case: "orgId", value: orgId } : { case: "instance", value: true }, + }); +} diff --git a/login/packages/zitadel-client/src/v3alpha.ts b/login/packages/zitadel-client/src/v3alpha.ts new file mode 100644 index 0000000000..a5cc533ade --- /dev/null +++ b/login/packages/zitadel-client/src/v3alpha.ts @@ -0,0 +1,6 @@ +import { ZITADELUsers } from "@zitadel/proto/zitadel/resources/user/v3alpha/user_service_pb.js"; +import { ZITADELUserSchemas } from "@zitadel/proto/zitadel/resources/userschema/v3alpha/user_schema_service_pb.js"; +import { createClientFor } from "./helpers.js"; + +export const createUserSchemaServiceClient = createClientFor(ZITADELUserSchemas); +export const createUserServiceClient = createClientFor(ZITADELUsers); diff --git a/login/packages/zitadel-client/src/web.ts b/login/packages/zitadel-client/src/web.ts new file mode 100644 index 0000000000..26c40da0fe --- /dev/null +++ b/login/packages/zitadel-client/src/web.ts @@ -0,0 +1,15 @@ +import { GrpcTransportOptions } from "@connectrpc/connect-node"; +import { createGrpcWebTransport } from "@connectrpc/connect-web"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +/** + * Create a client transport using grpc web with the given token and configuration options. + * @param token + * @param opts + */ +export function createClientTransport(token: string, opts: GrpcTransportOptions) { + return createGrpcWebTransport({ + ...opts, + interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + }); +} diff --git a/login/packages/zitadel-client/tsconfig.json b/login/packages/zitadel-client/tsconfig.json new file mode 100644 index 0000000000..5f0ea69110 --- /dev/null +++ b/login/packages/zitadel-client/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@zitadel/tsconfig/tsup.json", + "include": ["./src/**/*"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/login/packages/zitadel-client/tsup.config.ts b/login/packages/zitadel-client/tsup.config.ts new file mode 100644 index 0000000000..3c9eeb8b83 --- /dev/null +++ b/login/packages/zitadel-client/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts", "src/v1.ts", "src/v2.ts", "src/v3alpha.ts", "src/node.ts", "src/web.ts"], + format: ["esm", "cjs"], + treeshake: false, + splitting: true, + dts: true, + minify: false, + clean: true, + sourcemap: true, + ...options, +})); diff --git a/login/packages/zitadel-client/turbo.json b/login/packages/zitadel-client/turbo.json new file mode 100644 index 0000000000..2a042b5326 --- /dev/null +++ b/login/packages/zitadel-client/turbo.json @@ -0,0 +1,13 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"], + "dependsOn": ["@zitadel/proto#generate"] + }, + "build:standalone": { + "outputs": ["dist/**"], + "dependsOn": ["@zitadel/proto#generate"] + } + } +} diff --git a/login/packages/zitadel-eslint-config/CHANGELOG.md b/login/packages/zitadel-eslint-config/CHANGELOG.md new file mode 100644 index 0000000000..6759f857ff --- /dev/null +++ b/login/packages/zitadel-eslint-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/eslint-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-eslint-config/README.md b/login/packages/zitadel-eslint-config/README.md new file mode 100644 index 0000000000..d8d6851f91 --- /dev/null +++ b/login/packages/zitadel-eslint-config/README.md @@ -0,0 +1,35 @@ +# ZITADEL ESLint Config + +This package provides the ESLint configuration used by ZITADEL projects. It includes a set of rules and plugins to ensure consistent code quality and style across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/eslint-config +``` + +or + +```sh +yarn add @zitadel/eslint-config +``` + +## Usage + +To use the ESLint configuration in your project, extend it in your `.eslintrc` file: + +```js +{ + "extends": "@zitadel/eslint-config" +} +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [ESLint documentation](https://eslint.org/docs/user-guide/configuring). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-eslint-config/index.js b/login/packages/zitadel-eslint-config/index.js new file mode 100644 index 0000000000..6a53b2a5e6 --- /dev/null +++ b/login/packages/zitadel-eslint-config/index.js @@ -0,0 +1,13 @@ +module.exports = { + parser: "@babel/eslint-parser", + extends: ["next", "turbo", "prettier"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + parserOptions: { + requireConfigFile: false, + babelOptions: { + presets: ["next/babel"], + }, + }, +}; diff --git a/login/packages/zitadel-eslint-config/package.json b/login/packages/zitadel-eslint-config/package.json new file mode 100644 index 0000000000..84c9c76dab --- /dev/null +++ b/login/packages/zitadel-eslint-config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@zitadel/eslint-config", + "version": "0.1.1", + "main": "index.js", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@typescript-eslint/parser": "^7.9.0", + "eslint-config-next": "^14.2.18", + "eslint-config-prettier": "^9.1.0", + "eslint-config-turbo": "^2.0.9", + "eslint-plugin-react": "^7.34.1", + "@babel/eslint-parser": "^7.25.9" + } +} diff --git a/login/packages/zitadel-prettier-config/CHANGELOG.md b/login/packages/zitadel-prettier-config/CHANGELOG.md new file mode 100644 index 0000000000..83045c3320 --- /dev/null +++ b/login/packages/zitadel-prettier-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/prettier-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-prettier-config/README.md b/login/packages/zitadel-prettier-config/README.md new file mode 100644 index 0000000000..f33913273b --- /dev/null +++ b/login/packages/zitadel-prettier-config/README.md @@ -0,0 +1,36 @@ +# ZITADEL Prettier Config + +This package provides the Prettier configuration used by ZITADEL projects. It includes a set of formatting rules to ensure consistent code style across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/prettier-config +``` + +or + +```sh +yarn add @zitadel/prettier-config +``` + +## Usage + +To use the Prettier configuration in your project, extend it in your `prettier.config.js` file: + +```js +module.exports = { + ...require("@zitadel/prettier-config"), + // Add your custom configurations here +}; +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [Prettier documentation](https://prettier.io/docs/en/configuration.html). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-prettier-config/index.js b/login/packages/zitadel-prettier-config/index.js new file mode 100644 index 0000000000..31d8c455e8 --- /dev/null +++ b/login/packages/zitadel-prettier-config/index.js @@ -0,0 +1,11 @@ +export default { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + trailingComma: 'all', + bracketSpacing: true, + arrowParens: 'always', + plugins: ["prettier-plugin-organize-imports"] +}; diff --git a/login/packages/zitadel-prettier-config/package.json b/login/packages/zitadel-prettier-config/package.json new file mode 100644 index 0000000000..7b7cbf253e --- /dev/null +++ b/login/packages/zitadel-prettier-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zitadel/prettier-config", + "version": "0.1.1", + "description": "Prettier configuration", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./index.js" + } +} diff --git a/login/packages/zitadel-proto/.gitignore b/login/packages/zitadel-proto/.gitignore new file mode 100644 index 0000000000..93276fc105 --- /dev/null +++ b/login/packages/zitadel-proto/.gitignore @@ -0,0 +1,4 @@ +zitadel +google +protoc-gen-openapiv2 +validate diff --git a/login/packages/zitadel-proto/CHANGELOG.md b/login/packages/zitadel-proto/CHANGELOG.md new file mode 100644 index 0000000000..c3964e2b29 --- /dev/null +++ b/login/packages/zitadel-proto/CHANGELOG.md @@ -0,0 +1,47 @@ +# @zitadel/proto + +## 1.2.0 + +### Minor Changes + +- 62ad388: revert CJS support + +## 1.1.0 + +### Minor Changes + +- 9692297: add CJS and ESM support + +## 1.0.4 + +### Patch Changes + +- 97b0332: bind @zitadel/proto version to zitadel tag + +## 1.0.3 + +### Patch Changes + +- 90fbdd1: use node16/nodenext module resolution + +## 1.0.2 + +### Patch Changes + +- include validate, google and protoc-gen-openapiv2 + +## 1.0.1 + +### Patch Changes + +- README updates + +## 1.0.0 + +### Major Changes + +- 32e1199: Initial Release + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-proto/README.md b/login/packages/zitadel-proto/README.md new file mode 100644 index 0000000000..bf8a064c12 --- /dev/null +++ b/login/packages/zitadel-proto/README.md @@ -0,0 +1,35 @@ +# ZITADEL Proto + +This package provides the Protocol Buffers (proto) definitions used by ZITADEL projects. It includes the proto files and generated code for interacting with ZITADEL's gRPC APIs. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/proto +``` + +or + +```sh +yarn add @zitadel/proto +``` + +## Usage + +To use the proto definitions in your project, import the generated code: + +```ts +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; + +const org: Organization | null = await getDefaultOrg(); +``` + +## Documentation + +For detailed documentation and API references, please visit the [ZITADEL documentation](https://zitadel.com/docs). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-proto/buf.gen.yaml b/login/packages/zitadel-proto/buf.gen.yaml new file mode 100644 index 0000000000..84ecfaea9d --- /dev/null +++ b/login/packages/zitadel-proto/buf.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +managed: + enabled: true +plugins: + - remote: buf.build/bufbuild/es:v2.2.0 + out: . + include_imports: true + opt: + - json_types=true + - import_extension=js diff --git a/login/packages/zitadel-proto/package.json b/login/packages/zitadel-proto/package.json new file mode 100644 index 0000000000..61ef296616 --- /dev/null +++ b/login/packages/zitadel-proto/package.json @@ -0,0 +1,26 @@ +{ + "name": "@zitadel/proto", + "version": "1.2.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "zitadel/**", + "validate/**", + "google/**", + "protoc-gen-openapiv2/**" + ], + "sideEffects": false, + "scripts": { + "generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", + "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.2" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0" + } +} diff --git a/login/packages/zitadel-proto/turbo.json b/login/packages/zitadel-proto/turbo.json new file mode 100644 index 0000000000..2d24f0349b --- /dev/null +++ b/login/packages/zitadel-proto/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "generate": { + "outputs": ["zitadel/**"], + "cache": false + } + } +} diff --git a/login/packages/zitadel-tailwind-config/CHANGELOG.md b/login/packages/zitadel-tailwind-config/CHANGELOG.md new file mode 100644 index 0000000000..e3c4d7207e --- /dev/null +++ b/login/packages/zitadel-tailwind-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/tailwind-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-tailwind-config/README.md b/login/packages/zitadel-tailwind-config/README.md new file mode 100644 index 0000000000..f52b6c263b --- /dev/null +++ b/login/packages/zitadel-tailwind-config/README.md @@ -0,0 +1,36 @@ +# ZITADEL Tailwind Config + +This package provides the Tailwind CSS configuration used by ZITADEL projects. It includes a set of default styles, themes, and utility classes to ensure consistent design and styling across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/tailwind-config +``` + +or + +```sh +yarn add @zitadel/tailwind-config +``` + +## Usage + +To use the Tailwind CSS configuration in your project, extend it in your `tailwind.config.js` file: + +```js +module.exports = { + presets: [require("@zitadel/tailwind-config")], + // Add your custom configurations here +}; +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [Tailwind CSS documentation](https://tailwindcss.com/docs) + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-tailwind-config/package.json b/login/packages/zitadel-tailwind-config/package.json new file mode 100644 index 0000000000..8fba4bac95 --- /dev/null +++ b/login/packages/zitadel-tailwind-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zitadel/tailwind-config", + "version": "0.1.1", + "publishConfig": { + "access": "public" + }, + "main": "index.js", + "devDependencies": { + "tailwindcss": "^4.1.4", + "@tailwindcss/forms": "0.5.3" + } +} diff --git a/login/packages/zitadel-tailwind-config/tailwind.config.mjs b/login/packages/zitadel-tailwind-config/tailwind.config.mjs new file mode 100644 index 0000000000..4a9a437cb7 --- /dev/null +++ b/login/packages/zitadel-tailwind-config/tailwind.config.mjs @@ -0,0 +1,97 @@ +import colors from "tailwindcss/colors"; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./app/**/*.{js,ts,jsx,tsx}", "./page/**/*.{js,ts,jsx,tsx}", "./ui/**/*.{js,ts,jsx,tsx}"], + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + extend: { + // https://vercel.com/design/color + fontSize: { + "12px": "12px", + "14px": "14px", + }, + colors: { + gray: colors.zinc, + divider: { + dark: "rgba(135,149,161,.2)", + light: "rgba(135,149,161,.2)", + }, + input: { + light: { + label: "#000000c7", + background: "#00000004", + border: "#1a191954", + hoverborder: "1a1b1b", + }, + dark: { + label: "#ffffffc7", + background: "#00000020", + border: "#f9f7f775", + hoverborder: "#e0e0e0", + }, + }, + button: { + light: { + border: "#0000001f", + }, + dark: { + border: "#ffffff1f", + }, + }, + }, + backgroundImage: ({ theme }) => ({ + "dark-vc-border-gradient": `radial-gradient(at left top, ${theme( + "colors.gray.800", + )}, 50px, ${theme("colors.gray.800")} 50%)`, + "vc-border-gradient": `radial-gradient(at left top, ${theme( + "colors.gray.200", + )}, 50px, ${theme("colors.gray.300")} 50%)`, + }), + keyframes: ({ theme }) => ({ + rerender: { + "0%": { + ["border-color"]: theme("colors.pink.500"), + }, + "40%": { + ["border-color"]: theme("colors.pink.500"), + }, + }, + highlight: { + "0%": { + background: theme("colors.pink.500"), + color: theme("colors.white"), + }, + "40%": { + background: theme("colors.pink.500"), + color: theme("colors.white"), + }, + }, + shimmer: { + "100%": { + transform: "translateX(100%)", + }, + }, + translateXReset: { + "100%": { + transform: "translateX(0)", + }, + }, + fadeToTransparent: { + "0%": { + opacity: 1, + }, + "40%": { + opacity: 1, + }, + "100%": { + opacity: 0, + }, + }, + }), + }, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/login/packages/zitadel-tsconfig/CHANGELOG.md b/login/packages/zitadel-tsconfig/CHANGELOG.md new file mode 100644 index 0000000000..c2d2e7e31c --- /dev/null +++ b/login/packages/zitadel-tsconfig/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/tsconfig + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-tsconfig/README.md b/login/packages/zitadel-tsconfig/README.md new file mode 100644 index 0000000000..b93674b2b1 --- /dev/null +++ b/login/packages/zitadel-tsconfig/README.md @@ -0,0 +1,35 @@ +# ZITADEL TypeScript Config + +This package provides the TypeScript configuration used by ZITADEL projects. It includes a set of rules and settings to ensure consistent TypeScript configuration across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/tsconfig +``` + +or + +```sh +yarn add @zitadel/tsconfig +``` + +## Usage + +To use the TypeScript configuration in your project, extend it in your `tsconfig.json` file: + +```json +{ + "extends": "@zitadel/tsconfig/tsup.json" +} +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [TypeScript documentation](https://www.typescriptlang.org/docs/). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-tsconfig/base.json b/login/packages/zitadel-tsconfig/base.json new file mode 100644 index 0000000000..6d65860cce --- /dev/null +++ b/login/packages/zitadel-tsconfig/base.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "moduleResolution": "node16", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true + }, + "exclude": ["node_modules"] +} diff --git a/login/packages/zitadel-tsconfig/nextjs.json b/login/packages/zitadel-tsconfig/nextjs.json new file mode 100644 index 0000000000..eaa9942e1e --- /dev/null +++ b/login/packages/zitadel-tsconfig/nextjs.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Next.js", + "extends": "./base.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "preserveSymlinks": true, + "declaration": false, + "declarationMap": false, + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["src", "next-env.d.ts"], + "exclude": ["node_modules"] +} diff --git a/login/packages/zitadel-tsconfig/node20.json b/login/packages/zitadel-tsconfig/node20.json new file mode 100644 index 0000000000..bc88cfbee4 --- /dev/null +++ b/login/packages/zitadel-tsconfig/node20.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 20", + "extends": "./base.json", + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "target": "es2022" + } +} diff --git a/login/packages/zitadel-tsconfig/package.json b/login/packages/zitadel-tsconfig/package.json new file mode 100644 index 0000000000..a4d713db85 --- /dev/null +++ b/login/packages/zitadel-tsconfig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@zitadel/tsconfig", + "version": "0.1.1", + "publishConfig": { + "access": "public" + }, + "type": "module", + "license": "MIT" +} diff --git a/login/packages/zitadel-tsconfig/react-library.json b/login/packages/zitadel-tsconfig/react-library.json new file mode 100644 index 0000000000..3f6e3580f4 --- /dev/null +++ b/login/packages/zitadel-tsconfig/react-library.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "React Library", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["dom", "ES2015"], + "module": "preserve", + "moduleResolution": "Bundler" + } +} diff --git a/login/packages/zitadel-tsconfig/tsup.json b/login/packages/zitadel-tsconfig/tsup.json new file mode 100644 index 0000000000..1e5cbe42be --- /dev/null +++ b/login/packages/zitadel-tsconfig/tsup.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "tsup", + "extends": "./node20.json" +} diff --git a/login/playwright.config.ts b/login/playwright.config.ts new file mode 100644 index 0000000000..342a302461 --- /dev/null +++ b/login/playwright.config.ts @@ -0,0 +1,82 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./acceptance/tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:3000", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + /* + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + TODO: webkit fails. Is this a bug? + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run local dev server before starting the tests */ + + webServer: { + command: "pnpm start:built", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 5 * 60_000, + }, +}); diff --git a/login/pnpm-lock.yaml b/login/pnpm-lock.yaml new file mode 100644 index 0000000000..46a448c2f1 --- /dev/null +++ b/login/pnpm-lock.yaml @@ -0,0 +1,9573 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@typescript-eslint/parser': ^7.9.0 + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: ^2.29.2 + version: 2.29.2 + '@faker-js/faker': + specifier: ^9.7.0 + version: 9.7.0 + '@otplib/core': + specifier: ^12.0.0 + version: 12.0.1 + '@otplib/plugin-crypto': + specifier: ^12.0.0 + version: 12.0.1 + '@otplib/plugin-thirty-two': + specifier: ^12.0.0 + version: 12.0.1 + '@playwright/test': + specifier: ^1.52.0 + version: 1.52.0 + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.4.1(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + '@zitadel/eslint-config': + specifier: workspace:* + version: link:packages/zitadel-eslint-config + '@zitadel/prettier-config': + specifier: workspace:* + version: link:packages/zitadel-prettier-config + axios: + specifier: ^1.8.4 + version: 1.8.4(debug@4.4.0) + dotenv: + specifier: ^16.5.0 + version: 16.5.0 + eslint: + specifier: 8.57.1 + version: 8.57.1 + prettier: + specifier: ^3.5.3 + version: 3.5.3 + prettier-plugin-organize-imports: + specifier: ^4.1.0 + version: 4.1.0(prettier@3.5.3)(typescript@5.8.3) + tsup: + specifier: ^8.4.0 + version: 8.4.0(jiti@1.21.6)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.7.1) + turbo: + specifier: 2.5.0 + version: 2.5.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + vitest: + specifier: ^3.1.2 + version: 3.1.2(@types/node@22.14.1)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.87.0)(yaml@2.7.1) + + apps/login: + dependencies: + '@headlessui/react': + specifier: ^2.1.9 + version: 2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@heroicons/react': + specifier: 2.1.3 + version: 2.1.3(react@19.1.0) + '@tailwindcss/forms': + specifier: 0.5.7 + version: 0.5.7(tailwindcss@3.4.14) + '@vercel/analytics': + specifier: ^1.2.2 + version: 1.3.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + '@zitadel/client': + specifier: workspace:* + version: link:../../packages/zitadel-client + '@zitadel/proto': + specifier: workspace:* + version: link:../../packages/zitadel-proto + clsx: + specifier: 1.2.1 + version: 1.2.1 + copy-to-clipboard: + specifier: ^3.3.3 + version: 3.3.3 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 + jose: + specifier: ^5.3.0 + version: 5.8.0 + lucide-react: + specifier: 0.469.0 + version: 0.469.0(react@19.1.0) + moment: + specifier: ^2.29.4 + version: 2.30.1 + next: + specifier: 15.4.0-canary.3 + version: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next-intl: + specifier: ^3.25.1 + version: 3.25.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + next-themes: + specifier: ^0.2.1 + version: 0.2.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nice-grpc: + specifier: 2.0.1 + version: 2.0.1 + qrcode.react: + specifier: ^3.1.0 + version: 3.1.0(react@19.1.0) + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: 7.39.5 + version: 7.39.5(react@19.1.0) + swr: + specifier: ^2.2.0 + version: 2.2.5(react@19.1.0) + tinycolor2: + specifier: 1.4.2 + version: 1.4.2 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@types/ms': + specifier: 2.1.0 + version: 2.1.0 + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + '@types/react': + specifier: 19.1.2 + version: 19.1.2 + '@types/react-dom': + specifier: 19.1.2 + version: 19.1.2(@types/react@19.1.2) + '@types/tinycolor2': + specifier: 1.4.3 + version: 1.4.3 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@vercel/git-hooks': + specifier: 1.0.0 + version: 1.0.0 + '@zitadel/eslint-config': + specifier: workspace:* + version: link:../../packages/zitadel-eslint-config + '@zitadel/prettier-config': + specifier: workspace:* + version: link:../../packages/zitadel-prettier-config + '@zitadel/tailwind-config': + specifier: workspace:* + version: link:../../packages/zitadel-tailwind-config + '@zitadel/tsconfig': + specifier: workspace:* + version: link:../../packages/zitadel-tsconfig + autoprefixer: + specifier: 10.4.21 + version: 10.4.21(postcss@8.5.3) + concurrently: + specifier: ^9.1.2 + version: 9.1.2 + cypress: + specifier: ^14.3.2 + version: 14.3.2 + del-cli: + specifier: 6.0.0 + version: 6.0.0 + env-cmd: + specifier: ^10.0.0 + version: 10.1.0 + grpc-tools: + specifier: 1.13.0 + version: 1.13.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + lint-staged: + specifier: 15.5.1 + version: 15.5.1 + make-dir-cli: + specifier: 4.0.0 + version: 4.0.0 + nodemon: + specifier: ^3.1.9 + version: 3.1.9 + postcss: + specifier: 8.5.3 + version: 8.5.3 + prettier-plugin-tailwindcss: + specifier: 0.6.11 + version: 0.6.11(prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3))(prettier@3.5.3) + sass: + specifier: ^1.87.0 + version: 1.87.0 + start-server-and-test: + specifier: ^2.0.11 + version: 2.0.11 + tailwindcss: + specifier: 3.4.14 + version: 3.4.14 + ts-proto: + specifier: ^2.7.0 + version: 2.7.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + packages/zitadel-client: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.2 + version: 2.2.2 + '@connectrpc/connect': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2) + '@connectrpc/connect-node': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)) + '@connectrpc/connect-web': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)) + '@zitadel/proto': + specifier: workspace:* + version: link:../zitadel-proto + jose: + specifier: ^5.3.0 + version: 5.8.0 + devDependencies: + '@bufbuild/protocompile': + specifier: ^0.0.1 + version: 0.0.1(@bufbuild/buf@1.53.0) + '@zitadel/eslint-config': + specifier: workspace:* + version: link:../zitadel-eslint-config + '@zitadel/tsconfig': + specifier: workspace:* + version: link:../zitadel-tsconfig + + packages/zitadel-eslint-config: + dependencies: + '@babel/eslint-parser': + specifier: ^7.25.9 + version: 7.25.9(@babel/core@7.26.10)(eslint@8.57.1) + '@typescript-eslint/parser': + specifier: ^7.9.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint-config-next: + specifier: ^14.2.18 + version: 14.2.18(eslint@8.57.1)(typescript@5.8.3) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-config-turbo: + specifier: ^2.0.9 + version: 2.1.0(eslint@8.57.1) + eslint-plugin-react: + specifier: ^7.34.1 + version: 7.35.0(eslint@8.57.1) + + packages/zitadel-prettier-config: {} + + packages/zitadel-proto: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.2 + version: 2.2.2 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + + packages/zitadel-tailwind-config: + devDependencies: + '@tailwindcss/forms': + specifier: 0.5.3 + version: 0.5.3(tailwindcss@4.1.4) + tailwindcss: + specifier: ^4.1.4 + version: 4.1.4 + + packages/zitadel-tsconfig: {} + +packages: + + '@adobe/css-tools@4.4.0': + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.1.4': + resolution: {integrity: sha512-SeuBV4rnjpFNjI8HSgKUwteuFdkHwkboq31HWzznuqgySQir+jSTczoWVVL4jvOjKjuH80fMDG0Fvg1Sb+OJsA==} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.8': + resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.10': + resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==} + engines: {node: '>=6.9.0'} + + '@babel/eslint-parser@7.25.9': + resolution: {integrity: sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==} + engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} + peerDependencies: + '@babel/core': ^7.11.0 + eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 + + '@babel/generator@7.27.0': + resolution: {integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.0': + resolution: {integrity: sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.26.5': + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.27.0': + resolution: {integrity: sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.25.9': + resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.25.9': + resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.0': + resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.27.0': + resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} + engines: {node: '>=6.9.0'} + + '@bufbuild/buf-darwin-arm64@1.53.0': + resolution: {integrity: sha512-UVhqDYu54ciiCMeG6RODlrX5XRvLN6PfsVDqMQG0JwmMKtUi326CbUqsqO7xsQbcEUso3FTBaURir4RixoM88w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@bufbuild/buf-darwin-x64@1.53.0': + resolution: {integrity: sha512-03lKaenjf08HF6DlARPU2lEL2dRxNsU6rb9GbUu+YeLayWy7SUlfeDB8drAZ/GpfSc7SL8TKF7jqRkqxT4wFGA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@bufbuild/buf-linux-aarch64@1.53.0': + resolution: {integrity: sha512-FlxrB+rZJG5u7v2JovzXvSR/OdXjVXYHTTLnk6vN/73KPbpGPzZrW7mKxlYyn/Uar5tKDAYvmijjuItXZ6i31g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@bufbuild/buf-linux-armv7@1.53.0': + resolution: {integrity: sha512-e9ER+5Os1DPLhr2X1BRPrQpDZWpv5Mkk2PLnmmzh5RL4kOueJKQZj/m1qQr7SQkiPPhS0yMw7EEghsr521FFzQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@bufbuild/buf-linux-x64@1.53.0': + resolution: {integrity: sha512-LehyZPbkRgCvIM56uUnCAUD1QSno2wkBZ5HOvjrjOd0GEjfKgw/fsEu13fJR13bGBNOeOUHbHrd59iUSyY6rGA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@bufbuild/buf-win32-arm64@1.53.0': + resolution: {integrity: sha512-QRNMHYW6v4keoelIwMNZGQw2R67fsS8lEDnYxrFmiRADwZ/ri/XKJjvQfpoE2Bq0xREB0zZ++RX+1DZOkTA/Iw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@bufbuild/buf-win32-x64@1.53.0': + resolution: {integrity: sha512-relZlT9gYrZGcEH4dcJhEWrjaHV9drG1PcgW6krqw1AzpQOPxR/loXJ7DycoCAnUhQ9TdsdTfUlVHqiJt98piQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@bufbuild/buf@1.53.0': + resolution: {integrity: sha512-GGAztQbbKSv+HaihdDIUpejUcxIx2Fse9SqHfMisJbL/hZ7aOH7BFeSH0q8/g2kSAsLABlenVKeEWKX1uZU3LQ==} + engines: {node: '>=12'} + hasBin: true + + '@bufbuild/protobuf@2.2.2': + resolution: {integrity: sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==} + + '@bufbuild/protobuf@2.2.5': + resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==} + + '@bufbuild/protocompile@0.0.1': + resolution: {integrity: sha512-cOTMtjcWLcbjF17dPYgeMtVC5jZyS0bSjz3jy8kDPjOgjgSYMD2u2It7w8aCc2z23hTPIKl/2SNdMnz0Jzu3Xg==} + peerDependencies: + '@bufbuild/buf': ^1.22.0 + + '@changesets/apply-release-plan@7.0.12': + resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} + + '@changesets/assemble-release-plan@6.0.6': + resolution: {integrity: sha512-Frkj8hWJ1FRZiY3kzVCKzS0N5mMwWKwmv9vpam7vt8rZjLL1JMthdh6pSDVSPumHPshTTkKZ0VtNbE0cJHZZUg==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.29.2': + resolution: {integrity: sha512-vwDemKjGYMOc0l6WUUTGqyAWH3AmueeyoJa1KmFRtCYiCoY5K3B68ErYpDB6H48T4lLI4czum4IEjh6ildxUeg==} + hasBin: true + + '@changesets/config@3.1.1': + resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-release-plan@4.0.10': + resolution: {integrity: sha512-CCJ/f3edYaA3MqoEnWvGGuZm0uMEMzNJ97z9hdUR34AOvajSwySwsIzC/bBu3+kuGDsB+cny4FljG8UBWAa7jg==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.1': + resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.5': + resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@connectrpc/connect-node@2.0.0': + resolution: {integrity: sha512-DoI5T+SUvlS/8QBsxt2iDoUg15dSxqhckegrgZpWOtADtmGohBIVbx1UjtWmjLBrP4RdD0FeBw+XyRUSbpKnJQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0 + + '@connectrpc/connect-web@2.0.0': + resolution: {integrity: sha512-oeCxqHXLXlWJdmcvp9L3scgAuK+FjNSn+twyhUxc8yvDbTumnt5Io+LnBzSYxAdUdYqTw5yHfTSCJ4hj0QID0g==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0 + + '@connectrpc/connect@2.0.0': + resolution: {integrity: sha512-Usm8jgaaULANJU8vVnhWssSA6nrZ4DJEAbkNtXSoZay2YD5fDyMukCxu8NEhCvFzfHvrhxhcjttvgpyhOM7xAQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + + '@csstools/color-helpers@5.0.2': + resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.3': + resolution: {integrity: sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.9': + resolution: {integrity: sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + + '@cypress/request@3.0.8': + resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} + engines: {node: '>= 6'} + + '@cypress/xvfb@1.2.4': + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + + '@esbuild/aix-ppc64@0.25.2': + resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.2': + resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.2': + resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.2': + resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.2': + resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.2': + resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.2': + resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.2': + resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.2': + resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.2': + resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.2': + resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.2': + resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.2': + resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.2': + resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.2': + resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.2': + resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.2': + resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.2': + resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.2': + resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.2': + resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.2': + resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.2': + resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.2': + resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.2': + resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.2': + resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@faker-js/faker@9.7.0': + resolution: {integrity: sha512-aozo5vqjCmDoXLNUJarFZx2IN/GgGaogY4TMJ6so/WLZOWpSV7fvj2dmrV6sEAnUm1O7aCrhTibjpzeDFgNqbg==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + + '@floating-ui/dom@1.6.11': + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.24': + resolution: {integrity: sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + + '@formatjs/ecma402-abstract@2.2.4': + resolution: {integrity: sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg==} + + '@formatjs/fast-memoize@2.2.3': + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} + + '@formatjs/icu-messageformat-parser@2.9.4': + resolution: {integrity: sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg==} + + '@formatjs/icu-skeleton-parser@1.8.8': + resolution: {integrity: sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA==} + + '@formatjs/intl-localematcher@0.5.4': + resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} + + '@formatjs/intl-localematcher@0.5.8': + resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} + + '@grpc/grpc-js@1.11.1': + resolution: {integrity: sha512-gyt/WayZrVPH2w/UTLansS7F9Nwld472JxxaETamrM8HNlsa+jSLNyKAZmhxI2Me4c3mQHFiS1wWHDY1g1Kthw==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.13': + resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==} + engines: {node: '>=6'} + hasBin: true + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@headlessui/react@2.1.9': + resolution: {integrity: sha512-ckWw7vlKtnoa1fL2X0fx1a3t/Li9MIKDVXn3SgG65YlxvDAsNrY39PPCxVM7sQRA7go2fJsuHSSauKFNaJHH7A==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 + react-dom: ^18 + + '@heroicons/react@2.1.3': + resolution: {integrity: sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==} + peerDependencies: + react: '>= 16' + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@img/sharp-darwin-arm64@0.34.1': + resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.1': + resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.1.0': + resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.1.0': + resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.1': + resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.1': + resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.34.1': + resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.1': + resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.1': + resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.1': + resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.1': + resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.34.1': + resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.1': + resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@next/env@15.4.0-canary.3': + resolution: {integrity: sha512-lu4pB2e3Z/d+B0rxEm9YuQMb57Hd96iJUBZgVlcRNemlIryr0GByu17kvN6nBk3JjbWL8h+MW90stpGzGdhbqg==} + + '@next/eslint-plugin-next@14.2.18': + resolution: {integrity: sha512-KyYTbZ3GQwWOjX3Vi1YcQbekyGP0gdammb7pbmmi25HBUCINzDReyrzCMOJIeZisK1Q3U6DT5Rlc4nm2/pQeXA==} + + '@next/swc-darwin-arm64@15.4.0-canary.3': + resolution: {integrity: sha512-w9u8IpwLb/JS7HzHLt24smP4FxIYMgciOtYNUCognO1xh1XZfqqjDIrRAXDuuYDPKrc1i2EvI24R5eDTz7EYMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.4.0-canary.3': + resolution: {integrity: sha512-5pL1hBRw8h1XeArzWYjCDERtRFIfrMAz1Nq9m1np8FrTuHclE7xitKKfOJqqmBbO9dWtnZIfA8lZl9bdlNEUZg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.4.0-canary.3': + resolution: {integrity: sha512-vx6cU4jKoecF2QZw3CQqJrzb+D0WhNzHHoWUN8O+YKPnX0oG4wEtAQWSWisxKjNrU1U4TiraOql0nOQBUOKwaQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.4.0-canary.3': + resolution: {integrity: sha512-7ig1sQHRRgTrj4QHt5l8OT1z2SJnEAHbnEY9SDP2HilwQIfgOAOxveFDBR+f/8AMdAKhCTSeMyrZsivpC0xTUA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.4.0-canary.3': + resolution: {integrity: sha512-fML6pzNX9i3DlrCOdE6A1TbVL0aIQkIDDCjrbn/f37hOn88god1OrVd/d4J4w1YqLKQWpmJPnUn6Bkn8qXqbRw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.4.0-canary.3': + resolution: {integrity: sha512-87/JPkbr3fgvASdWW2qBVuaXwcjSxgy+CTllj2DgYB7e7BEzT7QJEdj0HJZljBjVbN5oT1FOKwhaVRgRWuwYLQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.4.0-canary.3': + resolution: {integrity: sha512-cTZh72h3ZX8z0lhdVs5m38uyy83mW5r0jz6hKagysPT06uTdOAypK6CRqG5CJSN7RM0n7CkfcO6ExjDqhkDhRA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.4.0-canary.3': + resolution: {integrity: sha512-8oZKOKRGad4EVZ94L5Sz2EP59khHIeKGKg+/z8r5mCbBtupLPTXmWjrXoi1R55hHRXJjbW2D5NwcPfJn/ltZ3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@otplib/core@12.0.1': + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + + '@otplib/plugin-crypto@12.0.1': + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + + '@otplib/plugin-thirty-two@12.0.1': + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.52.0': + resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==} + engines: {node: '>=18'} + hasBin: true + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@react-aria/focus@3.18.3': + resolution: {integrity: sha512-WKUElg+5zS0D3xlVn8MntNnkzJql2J6MuzAMP8Sv5WTgFDse/XGR842dsxPTIyKKdrWVCRegCuwa4m3n/GzgJw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/interactions@3.22.3': + resolution: {integrity: sha512-RRUb/aG+P0IKTIWikY/SylB6bIbLZeztnZY2vbe7RAG5MgVaCgn5HQ45SI15GlTmhsFG8CnF6slJsUFJiNHpbQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/ssr@3.9.6': + resolution: {integrity: sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/utils@3.25.3': + resolution: {integrity: sha512-PR5H/2vaD8fSq0H/UB9inNbc8KDcVmW6fYAfSWkkn+OAdhTTMVKqXXrZuZBWyFfSD5Ze7VN6acr4hrOQm2bmrA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-stately/utils@3.10.4': + resolution: {integrity: sha512-gBEQEIMRh5f60KCm7QKQ2WfvhB2gLUr9b72sqUdIZ2EG+xuPgaIlCBeSicvjmjBvYZwOjoOEnmIkcx2GHp/HWw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-types/shared@3.25.0': + resolution: {integrity: sha512-OZSyhzU6vTdW3eV/mz5i6hQwQUhkRs7xwY2d1aqPvTdMe0+2cY7Fwp45PAiwYLEj73i9ro2FxF9qC4DvHGSCgQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@rollup/rollup-android-arm-eabi@4.40.0': + resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.40.0': + resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.40.0': + resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.40.0': + resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.40.0': + resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.40.0': + resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.40.0': + resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.40.0': + resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.40.0': + resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.40.0': + resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==} + cpu: [x64] + os: [win32] + + '@rushstack/eslint-patch@1.10.4': + resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==} + + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@tailwindcss/forms@0.5.3': + resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + + '@tailwindcss/forms@0.5.7': + resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + + '@tanstack/react-virtual@3.10.6': + resolution: {integrity: sha512-xaSy6uUxB92O8mngHZ6CvbhGuqxQ5lIZWCBy+FjhrbHmOwc6BnOnKkYm2FsB1/BpKw/+FVctlMbEtI+F6I1aJg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@tanstack/virtual-core@3.10.6': + resolution: {integrity: sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==} + + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@22.14.1': + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + + '@types/react-dom@19.1.2': + resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.1.2': + resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==} + + '@types/sinonjs__fake-timers@8.1.1': + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + + '@types/sizzle@2.3.9': + resolution: {integrity: sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==} + + '@types/tinycolor2@1.4.3': + resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.15.0': + resolution: {integrity: sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^7.9.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/scope-manager@8.15.0': + resolution: {integrity: sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.15.0': + resolution: {integrity: sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/types@8.15.0': + resolution: {integrity: sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@8.15.0': + resolution: {integrity: sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.15.0': + resolution: {integrity: sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/visitor-keys@8.15.0': + resolution: {integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + '@vercel/analytics@1.3.1': + resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==} + peerDependencies: + next: '>= 13' + react: ^18 || ^19 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + + '@vercel/git-hooks@1.0.0': + resolution: {integrity: sha512-OxDFAAdyiJ/H0b8zR9rFCu3BIb78LekBXOphOYG3snV4ULhKFX387pBPpqZ9HLiRTejBWBxYEahkw79tuIgdAA==} + + '@vitejs/plugin-react@4.4.1': + resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + '@vitest/expect@3.1.2': + resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} + + '@vitest/mocker@3.1.2': + resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.1.2': + resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} + + '@vitest/runner@3.1.2': + resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} + + '@vitest/snapshot@3.1.2': + resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} + + '@vitest/spy@3.1.2': + resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} + + '@vitest/utils@3.1.2': + resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abort-controller-x@0.4.3: + resolution: {integrity: sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + axe-core@4.10.0: + resolution: {integrity: sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==} + engines: {node: '>=4'} + + axios@1.8.4: + resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + + axobject-query@3.1.1: + resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001680: + resolution: {integrity: sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==} + + caniuse-lite@1.0.30001715: + resolution: {integrity: sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==} + + case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + check-more-types@2.24.0: + resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} + engines: {node: '>= 0.8.0'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.2.0: + resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@9.1.2: + resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + engines: {node: '>=18'} + hasBin: true + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.5: + resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.3.1: + resolution: {integrity: sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==} + engines: {node: '>=18'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + cypress@14.3.2: + resolution: {integrity: sha512-n+yGD2ZFFKgy7I3YtVpZ7BcFYrrDMcKj713eOZdtxPttpBjCyw/R8dLlFSsJPouneGN7A/HOSRyPJ5+3/gKDoA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.5.0: + resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + del-cli@6.0.0: + resolution: {integrity: sha512-9nitGV2W6KLFyya4qYt4+9AKQFL+c0Ehj5K7V7IwlxTc6RMCfQUGY9E9pLG6e8TQjtwXpuiWIGGZb3mfVxyZkw==} + engines: {node: '>=18'} + hasBin: true + + del@8.0.0: + resolution: {integrity: sha512-R6ep6JJ+eOBZsBr9esiNN1gxFbZE4Q2cULkUSFumGYecAiS6qodDvcPx/sFuWHMNul7DWmrtoEOpYSm7o6tbSA==} + engines: {node: '>=18'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv@16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + + dprint-node@1.0.8: + resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + electron-to-chromium@1.5.140: + resolution: {integrity: sha512-o82Rj+ONp4Ip7Cl1r7lrqx/pXhbp/lh9DpKcMNscFJdh8ebyRofnc7Sh01B4jx403RI0oqTBvlZ7OBIZLMr2+Q==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + + env-cmd@10.1.0: + resolution: {integrity: sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==} + engines: {node: '>=8.0.0'} + hasBin: true + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-iterator-helpers@1.0.19: + resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.2: + resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@14.2.18: + resolution: {integrity: sha512-SuDRcpJY5VHBkhz5DijJ4iA4bVnBA0n48Rb+YSJSCDr+h7kKAcb1mZHusLbW+WA8LDB6edSolomXA55eG3eOVA==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-config-turbo@2.1.0: + resolution: {integrity: sha512-3SeE2OCWnkA/84adGJXABm++966LNGxRdXtXKBcplJdIe4PmERkov1z6Kzp2PrPKT13wGu/bwoLV5h1rm7v9ug==} + peerDependencies: + eslint: '>6.6.0' + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.6.3: + resolution: {integrity: sha512-ud9aw4szY9cCT1EWWdGv1L1XR6hh2PaRWif0j2QjQ0pgTY/69iw+W0Z4qZv5wHahOl8isEr+k/JnyAqNQkLkIA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.8.2: + resolution: {integrity: sha512-3XnC5fDyc8M4J2E8pt8pmSVRX2M+5yWMCfI/kDZwauQeFgzQOuhcRBFKjTeJagqgk4sFKxe1mvNVnaWwImx/Tg==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.29.1: + resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.9.0: + resolution: {integrity: sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.35.0: + resolution: {integrity: sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-plugin-turbo@2.1.0: + resolution: {integrity: sha512-+CWVY29y7Qa+gvrKSzP+TOYrHAlNLCh/97K5VtDdnpH54h/JFmnd3U0aSG6WANe0HgAK8NHQfeWFDdRzfDqbKA==} + peerDependencies: + eslint: '>6.6.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-stream@3.3.4: + resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + + eventemitter2@6.4.7: + resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.8.0: + resolution: {integrity: sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw==} + + getos@3.2.1: + resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@14.0.2: + resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + grpc-tools@1.13.0: + resolution: {integrity: sha512-7CbkJ1yWPfX0nHjbYG58BQThNhbICXBZynzCUxCb3LzX5X9B3hQbRY2STiRgIEiLILlK9fgl0z0QVGwPCdXf5g==} + hasBin: true + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-id@4.1.1: + resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} + hasBin: true + + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immutable@5.1.1: + resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + + intl-messageformat@10.7.7: + resolution: {integrity: sha512-F134jIoeYMro/3I0h08D0Yt4N9o9pjddU/4IIxMMURqbAtI2wu70X8hvG1V48W49zXHXv3RKSF/po+0fDfsGjA==} + + is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + + is-bun-module@1.1.0: + resolution: {integrity: sha512-4mTAVPlrXpaN3jtF0lsnPCMGnq4+qZjVIKq0HCpfcqf8OC1SM5oATCIAPM5V5FN05qp2NNnFndphmdZS9CV3hA==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-cwd@3.0.0: + resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + + is-weakset@2.0.3: + resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} + engines: {node: '>= 0.4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + + jose@5.8.0: + resolution: {integrity: sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + lazy-ass@1.6.0: + resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} + engines: {node: '> 0.8'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@15.5.1: + resolution: {integrity: sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@3.14.0: + resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} + engines: {node: '>=10.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + + listr2@8.3.2: + resolution: {integrity: sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==} + engines: {node: '>=18.0.0'} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.469.0: + resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-dir-cli@4.0.0: + resolution: {integrity: sha512-9BBC2CaGH0hUAx+tQthgxqYypwkTs+7oXmPdiWyDpHGo4mGB3kdudUKQGivK59C1aJroo4QLlXF7Chu/kdhYiw==} + engines: {node: '>=18'} + hasBin: true + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@5.0.0: + resolution: {integrity: sha512-G0yBotnlWVonPClw+tq+xi4K7DZC9n96HjGTBDdHkstAVsDkfZhi1sTvZypXLpyQTbISBkDtK0E5XlUqDsShQg==} + engines: {node: '>=18'} + + map-stream@0.1.0: + resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@3.25.1: + resolution: {integrity: sha512-Z2dJWn5f/b1sb8EmuJcuDhbQTIp4RG1KBFAILgRt/y27W0ifU7Ll/os3liphUY4InyRH89uShTAk7ItAlpr0uA==} + peerDependencies: + next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 + + next-themes@0.2.1: + resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + peerDependencies: + next: '*' + react: '*' + react-dom: '*' + + next@15.4.0-canary.3: + resolution: {integrity: sha512-OkwxAFNQeuE0vNL7tTwU+jm3nf3x3D5DHSmjRlFktsedGtxZiILZTq6UNExNaFBjttR+2Y6oGqRsFWXC4ob1Wg==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + nice-grpc-common@2.0.2: + resolution: {integrity: sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ==} + + nice-grpc@2.0.1: + resolution: {integrity: sha512-Q5CGXO08STsv+HAkXeFgRayANT62X1LnIDhNXdCf+LP0XaP7EiHM0Cr3QefnoFjDZAx/Kxq+qiQfY66BrtKcNQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + nodemon@3.1.9: + resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} + engines: {node: '>=10'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + nwsapi@2.2.20: + resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + object.entries@1.1.8: + resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-map@7.0.2: + resolution: {integrity: sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==} + engines: {node: '>=18'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path-type@5.0.0: + resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==} + engines: {node: '>=12'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + playwright-core@1.52.0: + resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.52.0: + resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + engines: {node: '>=18'} + hasBin: true + + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-organize-imports@4.1.0: + resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} + peerDependencies: + prettier: '>=2.0' + typescript: '>=2.9' + vue-tsc: ^2.1.0 + peerDependenciesMeta: + vue-tsc: + optional: true + + prettier-plugin-tailwindcss@0.6.11: + resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + protobufjs@7.4.0: + resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==} + engines: {node: '>=12.0.0'} + + proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + ps-tree@1.2.0: + resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} + engines: {node: '>= 0.10'} + hasBin: true + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qrcode.react@3.1.0: + resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + quansync@0.2.10: + resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-hook-form@7.39.5: + resolution: {integrity: sha512-OE0HKyz5IPc6svN2wd+e+evidZrw4O4WZWAWYzQVZuHi+hYnHFSLnxOq0ddjbdmaLIsLHut/ab7j72y2QT3+KA==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.6: + resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} + engines: {node: '>= 0.4'} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + + request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.40.0: + resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.87.0: + resolution: {integrity: sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==} + engines: {node: '>=14.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + sharp@0.34.1: + resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.2: + resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + split@0.3.3: + resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + start-server-and-test@2.0.11: + resolution: {integrity: sha512-TN39gLzPhHAflxyOkE/oMfQGj+pj3JgF6qVicFH/JrXt7xXktidKXwqfRga+ve7lVA8+RgPZVc25VrEPRScaDw==} + engines: {node: '>=16'} + hasBin: true + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + + stream-combiner@0.0.4: + resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string.prototype.includes@2.0.0: + resolution: {integrity: sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==} + + string.prototype.matchall@4.0.11: + resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + swr@2.2.5: + resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + + tailwindcss@3.4.14: + resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} + engines: {node: '>=14.0.0'} + hasBin: true + + tailwindcss@4.1.4: + resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thirty-two@1.0.2: + resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} + engines: {node: '>=0.2.6'} + + throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinycolor2@1.4.2: + resolution: {integrity: sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@1.4.1: + resolution: {integrity: sha512-5RU2/lxTA3YUZxju61HO2U6EoZLvBLtmV2mbTvqyu4a/7s7RmJPT+1YekhMVsQhznRWk/czIwDUg+V8Q9ZuG4w==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-error@1.0.6: + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-poet@6.11.0: + resolution: {integrity: sha512-r5AGF8vvb+GjBsnqiTqbLhN1/U2FJt6BI+k0dfCrkKzWvUhNlwMmq9nDHuucHs45LomgHjZPvYj96dD3JawjJA==} + + ts-proto-descriptors@2.0.0: + resolution: {integrity: sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==} + + ts-proto@2.7.0: + resolution: {integrity: sha512-BGHjse2wTOeswOqnnPKinpxmbaRd882so/e1En6ww59YMG7AO9Kg4vPpJcbVfrpBixPRDqHafXD/RDyd2T99GA==} + hasBin: true + + tsconfck@3.1.5: + resolution: {integrity: sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.4.0: + resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turbo-darwin-64@2.5.0: + resolution: {integrity: sha512-fP1hhI9zY8hv0idym3hAaXdPi80TLovmGmgZFocVAykFtOxF+GlfIgM/l4iLAV9ObIO4SUXPVWHeBZQQ+Hpjag==} + cpu: [x64] + os: [darwin] + + turbo-darwin-arm64@2.5.0: + resolution: {integrity: sha512-p9sYq7kXH7qeJwIQE86cOWv/xNqvow846l6c/qWc26Ib1ci5W7V0sI5thsrP3eH+VA0d+SHalTKg5SQXgNQBWA==} + cpu: [arm64] + os: [darwin] + + turbo-linux-64@2.5.0: + resolution: {integrity: sha512-1iEln2GWiF3iPPPS1HQJT6ZCFXynJPd89gs9SkggH2EJsj3eRUSVMmMC8y6d7bBbhBFsiGGazwFIYrI12zs6uQ==} + cpu: [x64] + os: [linux] + + turbo-linux-arm64@2.5.0: + resolution: {integrity: sha512-bKBcbvuQHmsX116KcxHJuAcppiiBOfivOObh2O5aXNER6mce7YDDQJy00xQQNp1DhEfcSV2uOsvb3O3nN2cbcA==} + cpu: [arm64] + os: [linux] + + turbo-windows-64@2.5.0: + resolution: {integrity: sha512-9BCo8oQ7BO7J0K913Czbc3tw8QwLqn2nTe4E47k6aVYkM12ASTScweXPTuaPFP5iYXAT6z5Dsniw704Ixa5eGg==} + cpu: [x64] + os: [win32] + + turbo-windows-arm64@2.5.0: + resolution: {integrity: sha512-OUHCV+ueXa3UzfZ4co/ueIHgeq9B2K48pZwIxKSm5VaLVuv8M13MhM7unukW09g++dpdrrE1w4IOVgxKZ0/exg==} + cpu: [arm64] + os: [win32] + + turbo@2.5.0: + resolution: {integrity: sha512-PvSRruOsitjy6qdqwIIyolv99+fEn57gP6gn4zhsHTEcCYgXPhv6BAxzAjleS8XKpo+Y582vTTA9nuqYDmbRuA==} + hasBin: true + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-intl@3.25.1: + resolution: {integrity: sha512-Xeyl0+BjlBf6fJr2h5W/CESZ2IQAH7jzXYK4c/ao+qR26jNPW3FXBLjg7eLRxdeI6QaLcYGLtH3WYhC9I0+6Yg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 + + use-sync-external-store@1.2.2: + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + vite-node@3.1.2: + resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@6.3.2: + resolution: {integrity: sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.1.2: + resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.2 + '@vitest/ui': 3.1.2 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wait-on@8.0.3: + resolution: {integrity: sha512-nQFqAFzZDeRxsu7S3C7LbuxslHhk+gnJZHyethuGKAn2IVleIbTB9I3vJSQiSR+DifUqmdzfPMoMPJfLqMF2vw==} + engines: {node: '>=12.0.0'} + hasBin: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + + which-builtin-type@1.1.4: + resolution: {integrity: sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.1: + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adobe/css-tools@4.4.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@asamuzakjp/css-color@3.1.4': + dependencies: + '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.8': {} + + '@babel/core@7.26.10': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.27.0 + '@babel/helper-compilation-targets': 7.27.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) + '@babel/helpers': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 + convert-source-map: 2.0.0 + debug: 4.4.0(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/eslint-parser@7.25.9(@babel/core@7.26.10)(eslint@8.57.1)': + dependencies: + '@babel/core': 7.26.10 + '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + semver: 6.3.1 + + '@babel/generator@7.27.0': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.0': + dependencies: + '@babel/compat-data': 7.26.8 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.27.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.26.5': {} + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helpers@7.27.0': + dependencies: + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 + + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.27.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + + '@babel/traverse@7.27.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 + debug: 4.4.0(supports-color@5.5.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.27.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@bufbuild/buf-darwin-arm64@1.53.0': + optional: true + + '@bufbuild/buf-darwin-x64@1.53.0': + optional: true + + '@bufbuild/buf-linux-aarch64@1.53.0': + optional: true + + '@bufbuild/buf-linux-armv7@1.53.0': + optional: true + + '@bufbuild/buf-linux-x64@1.53.0': + optional: true + + '@bufbuild/buf-win32-arm64@1.53.0': + optional: true + + '@bufbuild/buf-win32-x64@1.53.0': + optional: true + + '@bufbuild/buf@1.53.0': + optionalDependencies: + '@bufbuild/buf-darwin-arm64': 1.53.0 + '@bufbuild/buf-darwin-x64': 1.53.0 + '@bufbuild/buf-linux-aarch64': 1.53.0 + '@bufbuild/buf-linux-armv7': 1.53.0 + '@bufbuild/buf-linux-x64': 1.53.0 + '@bufbuild/buf-win32-arm64': 1.53.0 + '@bufbuild/buf-win32-x64': 1.53.0 + + '@bufbuild/protobuf@2.2.2': {} + + '@bufbuild/protobuf@2.2.5': {} + + '@bufbuild/protocompile@0.0.1(@bufbuild/buf@1.53.0)': + dependencies: + '@bufbuild/buf': 1.53.0 + '@bufbuild/protobuf': 2.2.2 + fflate: 0.8.2 + + '@changesets/apply-release-plan@7.0.12': + dependencies: + '@changesets/config': 3.1.1 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.1 + + '@changesets/assemble-release-plan@6.0.6': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.1 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.29.2': + dependencies: + '@changesets/apply-release-plan': 7.0.12 + '@changesets/assemble-release-plan': 6.0.6 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.1 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.10 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + external-editor: 3.1.0 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.1 + spawndamnit: 3.0.1 + term-size: 2.2.1 + + '@changesets/config@3.1.1': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.1 + + '@changesets/get-release-plan@4.0.10': + dependencies: + '@changesets/assemble-release-plan': 6.0.6 + '@changesets/config': 3.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.1': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 3.14.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.5': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.1 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.1 + prettier: 2.8.8 + + '@colors/colors@1.5.0': + optional: true + + '@connectrpc/connect-node@2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2))': + dependencies: + '@bufbuild/protobuf': 2.2.2 + '@connectrpc/connect': 2.0.0(@bufbuild/protobuf@2.2.2) + + '@connectrpc/connect-web@2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2))': + dependencies: + '@bufbuild/protobuf': 2.2.2 + '@connectrpc/connect': 2.0.0(@bufbuild/protobuf@2.2.2) + + '@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)': + dependencies: + '@bufbuild/protobuf': 2.2.2 + + '@csstools/color-helpers@5.0.2': {} + + '@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.2 + '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + + '@cypress/request@3.0.8': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.2 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.0 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + + '@emnapi/runtime@1.4.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.2': + optional: true + + '@esbuild/android-arm64@0.25.2': + optional: true + + '@esbuild/android-arm@0.25.2': + optional: true + + '@esbuild/android-x64@0.25.2': + optional: true + + '@esbuild/darwin-arm64@0.25.2': + optional: true + + '@esbuild/darwin-x64@0.25.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.2': + optional: true + + '@esbuild/freebsd-x64@0.25.2': + optional: true + + '@esbuild/linux-arm64@0.25.2': + optional: true + + '@esbuild/linux-arm@0.25.2': + optional: true + + '@esbuild/linux-ia32@0.25.2': + optional: true + + '@esbuild/linux-loong64@0.25.2': + optional: true + + '@esbuild/linux-mips64el@0.25.2': + optional: true + + '@esbuild/linux-ppc64@0.25.2': + optional: true + + '@esbuild/linux-riscv64@0.25.2': + optional: true + + '@esbuild/linux-s390x@0.25.2': + optional: true + + '@esbuild/linux-x64@0.25.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.2': + optional: true + + '@esbuild/netbsd-x64@0.25.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.2': + optional: true + + '@esbuild/openbsd-x64@0.25.2': + optional: true + + '@esbuild/sunos-x64@0.25.2': + optional: true + + '@esbuild/win32-arm64@0.25.2': + optional: true + + '@esbuild/win32-ia32@0.25.2': + optional: true + + '@esbuild/win32-x64@0.25.2': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.1': {} + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.0(supports-color@5.5.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@faker-js/faker@9.7.0': {} + + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + + '@floating-ui/dom@1.6.11': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + + '@floating-ui/react-dom@2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.6.11 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.26.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.8 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.8': {} + + '@formatjs/ecma402-abstract@2.2.4': + dependencies: + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.8 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.3': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.9.4': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/icu-skeleton-parser': 1.8.8 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.8': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.4': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.8': + dependencies: + tslib: 2.8.1 + + '@grpc/grpc-js@1.11.1': + dependencies: + '@grpc/proto-loader': 0.7.13 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.13': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.2.3 + protobufjs: 7.4.0 + yargs: 17.7.2 + + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@headlessui/react@2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react': 0.26.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/focus': 3.18.3(react@19.1.0) + '@react-aria/interactions': 3.22.3(react@19.1.0) + '@tanstack/react-virtual': 3.10.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@heroicons/react@2.1.3(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.0(supports-color@5.5.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@img/sharp-darwin-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + + '@img/sharp-darwin-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + + '@img/sharp-libvips-linux-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + optional: true + + '@img/sharp-linux-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + + '@img/sharp-linux-arm@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-s390x@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + + '@img/sharp-linux-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + optional: true + + '@img/sharp-wasm32@0.34.1': + dependencies: + '@emnapi/runtime': 1.4.3 + optional: true + + '@img/sharp-win32-ia32@0.34.1': + optional: true + + '@img/sharp-win32-x64@0.34.1': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.27.0 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.27.0 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.0.4 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.1 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@next/env@15.4.0-canary.3': {} + + '@next/eslint-plugin-next@14.2.18': + dependencies: + glob: 10.3.10 + + '@next/swc-darwin-arm64@15.4.0-canary.3': + optional: true + + '@next/swc-darwin-x64@15.4.0-canary.3': + optional: true + + '@next/swc-linux-arm64-gnu@15.4.0-canary.3': + optional: true + + '@next/swc-linux-arm64-musl@15.4.0-canary.3': + optional: true + + '@next/swc-linux-x64-gnu@15.4.0-canary.3': + optional: true + + '@next/swc-linux-x64-musl@15.4.0-canary.3': + optional: true + + '@next/swc-win32-arm64-msvc@15.4.0-canary.3': + optional: true + + '@next/swc-win32-x64-msvc@15.4.0-canary.3': + optional: true + + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + dependencies: + eslint-scope: 5.1.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@otplib/core@12.0.1': {} + + '@otplib/plugin-crypto@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + + '@otplib/plugin-thirty-two@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.52.0': + dependencies: + playwright: 1.52.0 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@react-aria/focus@3.18.3(react@19.1.0)': + dependencies: + '@react-aria/interactions': 3.22.3(react@19.1.0) + '@react-aria/utils': 3.25.3(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.5 + clsx: 2.1.1 + react: 19.1.0 + + '@react-aria/interactions@3.22.3(react@19.1.0)': + dependencies: + '@react-aria/ssr': 3.9.6(react@19.1.0) + '@react-aria/utils': 3.25.3(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.5 + react: 19.1.0 + + '@react-aria/ssr@3.9.6(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.1.0 + + '@react-aria/utils@3.25.3(react@19.1.0)': + dependencies: + '@react-aria/ssr': 3.9.6(react@19.1.0) + '@react-stately/utils': 3.10.4(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.1.0 + + '@react-stately/utils@3.10.4(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.1.0 + + '@react-types/shared@3.25.0(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@rollup/rollup-android-arm-eabi@4.40.0': + optional: true + + '@rollup/rollup-android-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-x64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.40.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.40.0': + optional: true + + '@rushstack/eslint-patch@1.10.4': {} + + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + + '@sindresorhus/merge-streams@2.3.0': {} + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@tailwindcss/forms@0.5.3(tailwindcss@4.1.4)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.4 + + '@tailwindcss/forms@0.5.7(tailwindcss@3.4.14)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.14 + + '@tanstack/react-virtual@3.10.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.10.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.10.6': {} + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.0 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.0 + '@testing-library/dom': 10.4.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.0 + + '@types/estree@1.0.7': {} + + '@types/json5@0.0.29': {} + + '@types/ms@2.1.0': {} + + '@types/node@12.20.55': {} + + '@types/node@22.14.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.1.2(@types/react@19.1.2)': + dependencies: + '@types/react': 19.1.2 + + '@types/react@19.1.2': + dependencies: + csstype: 3.1.3 + + '@types/sinonjs__fake-timers@8.1.1': {} + + '@types/sizzle@2.3.9': {} + + '@types/tinycolor2@1.4.3': {} + + '@types/uuid@10.0.0': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.14.1 + optional: true + + '@typescript-eslint/eslint-plugin@8.15.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.15.0 + '@typescript-eslint/type-utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.15.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/scope-manager@8.15.0': + dependencies: + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/visitor-keys': 8.15.0 + + '@typescript-eslint/type-utils@8.15.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + debug: 4.4.0(supports-color@5.5.0) + eslint: 8.57.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/types@8.15.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.0(supports-color@5.5.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.15.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/visitor-keys': 8.15.0 + debug: 4.4.0(supports-color@5.5.0) + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.15.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.15.0 + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.8.3) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@8.15.0': + dependencies: + '@typescript-eslint/types': 8.15.0 + eslint-visitor-keys: 4.2.0 + + '@ungap/structured-clone@1.2.0': {} + + '@vercel/analytics@1.3.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': + dependencies: + server-only: 0.0.1 + optionalDependencies: + next: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + + '@vercel/git-hooks@1.0.0': {} + + '@vitejs/plugin-react@4.4.1(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1))': + dependencies: + '@babel/core': 7.26.10 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10) + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.1.2': + dependencies: + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.1.2(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + + '@vitest/pretty-format@3.1.2': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.1.2': + dependencies: + '@vitest/utils': 3.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.1.2': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + + abbrev@1.1.1: {} + + abort-controller-x@0.4.3: {} + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.0(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + agent-base@7.1.3: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + aproba@2.0.0: {} + + arch@2.2.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + + array-includes@3.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + + array-union@2.1.0: {} + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.findlastindex@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.flat@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + + array.prototype.flatmap@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-shim-unscopables: 1.0.2 + + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + astral-regex@2.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001715 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + axe-core@4.10.0: {} + + axios@1.8.4(debug@4.4.0): + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axobject-query@3.1.1: + dependencies: + deep-equal: 2.2.3 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + binary-extensions@2.3.0: {} + + blob-util@2.0.2: {} + + bluebird@3.7.2: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001715 + electron-to-chromium: 1.5.140 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + buffer-crc32@0.2.13: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-require@5.1.0(esbuild@0.25.2): + dependencies: + esbuild: 0.25.2 + load-tsconfig: 0.2.5 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + cac@6.7.14: {} + + cachedir@2.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001680: {} + + caniuse-lite@1.0.30001715: {} + + case-anything@2.1.13: {} + + caseless@0.12.0: {} + + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.4.1: {} + + chardet@0.7.0: {} + + check-error@2.1.1: {} + + check-more-types@2.24.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@2.0.0: {} + + ci-info@3.9.0: {} + + ci-info@4.2.0: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@1.2.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color-support@1.1.3: {} + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + + commander@4.1.1: {} + + commander@6.2.1: {} + + common-tags@1.8.2: {} + + concat-map@0.0.1: {} + + concurrently@9.1.2: + dependencies: + chalk: 4.1.2 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + consola@3.4.2: {} + + console-control-strings@1.1.0: {} + + convert-source-map@2.0.0: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + core-util-is@1.0.2: {} + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.5: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@4.3.1: + dependencies: + '@asamuzakjp/css-color': 3.1.4 + rrweb-cssom: 0.8.0 + + csstype@3.1.3: {} + + cypress@14.3.2: + dependencies: + '@cypress/request': 3.0.8 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.9 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + check-more-types: 2.24.0 + ci-info: 4.2.0 + cli-cursor: 3.1.0 + cli-table3: 0.6.5 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.13 + debug: 4.4.0(supports-color@8.1.1) + enquirer: 2.4.1 + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + figures: 3.2.0 + fs-extra: 9.1.0 + getos: 3.2.1 + is-installed-globally: 0.4.0 + lazy-ass: 1.6.0 + listr2: 3.14.0(enquirer@2.4.1) + lodash: 4.17.21 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + semver: 7.7.1 + supports-color: 8.1.1 + tmp: 0.2.3 + tree-kill: 1.2.2 + untildify: 4.0.0 + yauzl: 2.10.0 + + damerau-levenshtein@1.0.8: {} + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + dayjs@1.11.13: {} + + debug@3.2.7(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.0(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + debug@4.4.0(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decimal.js@10.5.0: {} + + deep-eql@5.0.2: {} + + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.1.1 + is-array-buffer: 3.0.4 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + side-channel: 1.1.0 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + del-cli@6.0.0: + dependencies: + del: 8.0.0 + meow: 13.2.0 + + del@8.0.0: + dependencies: + globby: 14.0.2 + is-glob: 4.0.3 + is-path-cwd: 3.0.0 + is-path-inside: 4.0.0 + p-map: 7.0.2 + slash: 5.1.0 + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + detect-libc@1.0.3: {} + + detect-libc@2.0.3: + optional: true + + detect-libc@2.0.4: {} + + didyoumean@1.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv@16.0.3: {} + + dotenv@16.5.0: {} + + dprint-node@1.0.8: + dependencies: + detect-libc: 1.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + electron-to-chromium@1.5.140: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.17.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@6.0.0: {} + + env-cmd@10.1.0: + dependencies: + commander: 4.1.1 + cross-spawn: 7.0.5 + + environment@1.1.0: {} + + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.3.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.1.1 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + + es-iterator-helpers@1.0.19: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-set-tostringtag: 2.0.3 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + iterator.prototype: 1.1.2 + safe-array-concat: 1.1.2 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.0.2: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + esbuild@0.25.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.2 + '@esbuild/android-arm': 0.25.2 + '@esbuild/android-arm64': 0.25.2 + '@esbuild/android-x64': 0.25.2 + '@esbuild/darwin-arm64': 0.25.2 + '@esbuild/darwin-x64': 0.25.2 + '@esbuild/freebsd-arm64': 0.25.2 + '@esbuild/freebsd-x64': 0.25.2 + '@esbuild/linux-arm': 0.25.2 + '@esbuild/linux-arm64': 0.25.2 + '@esbuild/linux-ia32': 0.25.2 + '@esbuild/linux-loong64': 0.25.2 + '@esbuild/linux-mips64el': 0.25.2 + '@esbuild/linux-ppc64': 0.25.2 + '@esbuild/linux-riscv64': 0.25.2 + '@esbuild/linux-s390x': 0.25.2 + '@esbuild/linux-x64': 0.25.2 + '@esbuild/netbsd-arm64': 0.25.2 + '@esbuild/netbsd-x64': 0.25.2 + '@esbuild/openbsd-arm64': 0.25.2 + '@esbuild/openbsd-x64': 0.25.2 + '@esbuild/sunos-x64': 0.25.2 + '@esbuild/win32-arm64': 0.25.2 + '@esbuild/win32-ia32': 0.25.2 + '@esbuild/win32-x64': 0.25.2 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@14.2.18(eslint@8.57.1)(typescript@5.8.3): + dependencies: + '@next/eslint-plugin-next': 14.2.18 + '@rushstack/eslint-patch': 1.10.4 + '@typescript-eslint/eslint-plugin': 8.15.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.1) + eslint-plugin-react: 7.35.0(eslint@8.57.1) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-config-prettier@9.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-config-turbo@2.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-turbo: 2.1.0(eslint@8.57.1) + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7(supports-color@8.1.1) + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.0(supports-color@5.5.0) + enhanced-resolve: 5.17.1 + eslint: 8.57.1 + eslint-module-utils: 2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + fast-glob: 3.3.2 + get-tsconfig: 4.8.0 + is-bun-module: 1.1.0 + is-glob: 4.0.3 + optionalDependencies: + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + + eslint-module-utils@2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + dependencies: + debug: 3.2.7(supports-color@8.1.1) + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7(supports-color@8.1.1) + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.9.0(eslint@8.57.1): + dependencies: + aria-query: 5.1.3 + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 + ast-types-flow: 0.0.8 + axe-core: 4.10.0 + axobject-query: 3.1.1 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.0.3 + string.prototype.includes: 2.0.0 + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react@7.35.0(eslint@8.57.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.8 + object.fromentries: 2.0.8 + object.values: 1.2.0 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.11 + string.prototype.repeat: 1.0.0 + + eslint-plugin-turbo@2.1.0(eslint@8.57.1): + dependencies: + dotenv: 16.0.3 + eslint: 8.57.1 + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@2.1.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + + esutils@2.0.3: {} + + event-stream@3.3.4: + dependencies: + duplexer: 0.1.2 + from: 0.1.7 + map-stream: 0.1.0 + pause-stream: 0.0.11 + split: 0.3.3 + stream-combiner: 0.0.4 + through: 2.3.8 + + eventemitter2@6.4.7: {} + + eventemitter3@5.0.1: {} + + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + executable@4.1.1: + dependencies: + pify: 2.3.0 + + expect-type@1.2.1: {} + + extend@3.0.2: {} + + extendable-error@0.1.7: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + extract-zip@2.0.1(supports-color@8.1.1): + dependencies: + debug: 4.4.0(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fflate@0.8.2: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.1: {} + + follow-redirects@1.15.9(debug@4.4.0): + optionalDependencies: + debug: 4.4.0(supports-color@5.5.0) + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forever-agent@0.6.1: {} + + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + + fraction.js@4.3.7: {} + + from@0.1.7: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + + functions-have-names@1.2.3: {} + + gauge@3.0.2: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.2 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + + get-tsconfig@4.8.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getos@3.2.1: + dependencies: + async: 3.2.6 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.10: + dependencies: + foreground-child: 3.3.0 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 7.1.2 + path-scurry: 1.11.1 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + globals@11.12.0: {} + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globby@14.0.2: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.2 + ignore: 5.3.2 + path-type: 5.0.0 + slash: 5.1.0 + unicorn-magic: 0.1.0 + + globrex@0.1.2: {} + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + grpc-tools@1.13.0: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + transitivePeerDependencies: + - encoding + - supports-color + + has-bigints@1.0.2: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.0(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + human-id@4.1.1: {} + + human-signals@1.1.1: {} + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + immutable@5.1.1: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@2.0.0: {} + + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + + intl-messageformat@10.7.7: + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/icu-messageformat-parser': 2.9.4 + tslib: 2.8.1 + + is-arguments@1.1.1: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + + is-arrayish@0.3.2: + optional: true + + is-async-function@2.0.0: + dependencies: + has-tostringtag: 1.0.2 + + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-bun-module@1.1.0: + dependencies: + semver: 7.7.1 + + is-callable@1.2.7: {} + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-generator-function@1.0.10: + dependencies: + has-tostringtag: 1.0.2 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-cwd@3.0.0: {} + + is-path-inside@3.0.3: {} + + is-path-inside@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + + is-typedarray@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-weakset@2.0.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + + is-windows@1.0.2: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isstream@0.1.2: {} + + iterator.prototype@1.1.2: + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.6 + set-function-name: 2.0.2 + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.6: {} + + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + + jose@5.8.0: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsbn@0.1.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.3.1 + data-urls: 5.0.0 + decimal.js: 10.5.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.20 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.8 + array.prototype.flat: 1.3.2 + object.assign: 4.1.5 + object.values: 1.2.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + lazy-ass@1.6.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lint-staged@15.5.1: + dependencies: + chalk: 5.4.1 + commander: 13.1.0 + debug: 4.4.0(supports-color@5.5.0) + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.2 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.7.1 + transitivePeerDependencies: + - supports-color + + listr2@3.14.0(enquirer@2.4.1): + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.4.1 + rxjs: 7.8.2 + through: 2.3.8 + wrap-ansi: 7.0.0 + optionalDependencies: + enquirer: 2.4.1 + + listr2@8.3.2: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + load-tsconfig@0.2.5: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.startcase@4.4.0: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@4.0.0: + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + long@5.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.1.3: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.469.0(react@19.1.0): + dependencies: + react: 19.1.0 + + lz-string@1.5.0: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + make-dir-cli@4.0.0: + dependencies: + make-dir: 5.0.0 + meow: 13.2.0 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@5.0.0: {} + + map-stream@0.1.0: {} + + math-intrinsics@1.1.0: {} + + meow@13.2.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + mini-svg-data-uri@1.4.4: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + moment@2.30.1: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next-intl@3.25.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): + dependencies: + '@formatjs/intl-localematcher': 0.5.4 + negotiator: 1.0.0 + next: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + use-intl: 3.25.1(react@19.1.0) + + next-themes@0.2.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + next: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): + dependencies: + '@next/env': 15.4.0-canary.3 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001680 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.4.0-canary.3 + '@next/swc-darwin-x64': 15.4.0-canary.3 + '@next/swc-linux-arm64-gnu': 15.4.0-canary.3 + '@next/swc-linux-arm64-musl': 15.4.0-canary.3 + '@next/swc-linux-x64-gnu': 15.4.0-canary.3 + '@next/swc-linux-x64-musl': 15.4.0-canary.3 + '@next/swc-win32-arm64-msvc': 15.4.0-canary.3 + '@next/swc-win32-x64-msvc': 15.4.0-canary.3 + '@playwright/test': 1.52.0 + sass: 1.87.0 + sharp: 0.34.1 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + nice-grpc-common@2.0.2: + dependencies: + ts-error: 1.0.6 + + nice-grpc@2.0.1: + dependencies: + '@grpc/grpc-js': 1.11.1 + abort-controller-x: 0.4.3 + nice-grpc-common: 2.0.2 + + node-addon-api@7.1.1: + optional: true + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-releases@2.0.19: {} + + nodemon@3.1.9: + dependencies: + chokidar: 3.6.0 + debug: 4.4.0(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.1 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + nwsapi@2.2.20: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.2: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + object.entries@1.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + + object.values@1.2.0: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + os-tmpdir@1.0.2: {} + + ospath@1.2.2: {} + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-map@7.0.2: {} + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.10 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-type@4.0.0: {} + + path-type@5.0.0: {} + + pathe@2.0.3: {} + + pathval@2.0.0: {} + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + pend@1.2.0: {} + + performance-now@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + pidtree@0.6.0: {} + + pify@2.3.0: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + playwright-core@1.52.0: {} + + playwright@1.52.0: + dependencies: + playwright-core: 1.52.0 + optionalDependencies: + fsevents: 2.3.2 + + possible-typed-array-names@1.0.0: {} + + postcss-import@15.1.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.5.3): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.3 + + postcss-load-config@4.0.2(postcss@8.5.3): + dependencies: + lilconfig: 3.1.3 + yaml: 2.7.1 + optionalDependencies: + postcss: 8.5.3 + + postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.5.3)(yaml@2.7.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.6 + postcss: 8.5.3 + yaml: 2.7.1 + + postcss-nested@6.2.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3): + dependencies: + prettier: 3.5.3 + typescript: 5.8.3 + + prettier-plugin-tailwindcss@0.6.11(prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3))(prettier@3.5.3): + dependencies: + prettier: 3.5.3 + optionalDependencies: + prettier-plugin-organize-imports: 4.1.0(prettier@3.5.3)(typescript@5.8.3) + + prettier@2.8.8: {} + + prettier@3.5.3: {} + + pretty-bytes@5.6.0: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process@0.11.10: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + protobufjs@7.4.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.14.1 + long: 5.2.3 + + proxy-from-env@1.0.0: {} + + proxy-from-env@1.1.0: {} + + ps-tree@1.2.0: + dependencies: + event-stream: 3.3.4 + + pstree.remy@1.1.8: {} + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode@2.3.1: {} + + qrcode.react@3.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.10: {} + + queue-microtask@1.2.3: {} + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-hook-form@7.39.5(react@19.1.0): + dependencies: + react: 19.1.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react@19.1.0: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.1 + pify: 4.0.1 + strip-bom: 3.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.2: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + which-builtin-type: 1.1.4 + + regenerator-runtime@0.14.1: {} + + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + request-progress@3.0.0: + dependencies: + throttleit: 1.0.1 + + require-directory@2.1.1: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.40.0: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.40.0 + '@rollup/rollup-android-arm64': 4.40.0 + '@rollup/rollup-darwin-arm64': 4.40.0 + '@rollup/rollup-darwin-x64': 4.40.0 + '@rollup/rollup-freebsd-arm64': 4.40.0 + '@rollup/rollup-freebsd-x64': 4.40.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.40.0 + '@rollup/rollup-linux-arm-musleabihf': 4.40.0 + '@rollup/rollup-linux-arm64-gnu': 4.40.0 + '@rollup/rollup-linux-arm64-musl': 4.40.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.40.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-musl': 4.40.0 + '@rollup/rollup-linux-s390x-gnu': 4.40.0 + '@rollup/rollup-linux-x64-gnu': 4.40.0 + '@rollup/rollup-linux-x64-musl': 4.40.0 + '@rollup/rollup-win32-arm64-msvc': 4.40.0 + '@rollup/rollup-win32-ia32-msvc': 4.40.0 + '@rollup/rollup-win32-x64-msvc': 4.40.0 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + + safer-buffer@2.1.2: {} + + sass@1.87.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.1 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.1: {} + + server-only@0.0.1: {} + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + sharp@0.34.1: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.7.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.1 + '@img/sharp-darwin-x64': 0.34.1 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 + '@img/sharp-libvips-linux-x64': 1.1.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.1 + '@img/sharp-linux-arm64': 0.34.1 + '@img/sharp-linux-s390x': 0.34.1 + '@img/sharp-linux-x64': 0.34.1 + '@img/sharp-linuxmusl-arm64': 0.34.1 + '@img/sharp-linuxmusl-x64': 0.34.1 + '@img/sharp-wasm32': 0.34.1 + '@img/sharp-win32-ia32': 0.34.1 + '@img/sharp-win32-x64': 0.34.1 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.2: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.2 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.1 + + slash@3.0.0: {} + + slash@5.1.0: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + source-map-js@1.2.1: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + split@0.3.3: + dependencies: + through: 2.3.8 + + sprintf-js@1.0.3: {} + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + stackback@0.0.2: {} + + start-server-and-test@2.0.11: + dependencies: + arg: 5.0.2 + bluebird: 3.7.2 + check-more-types: 2.24.0 + debug: 4.4.0(supports-color@5.5.0) + execa: 5.1.1 + lazy-ass: 1.6.0 + ps-tree: 1.2.0 + wait-on: 8.0.3(debug@4.4.0) + transitivePeerDependencies: + - supports-color + + std-env@3.9.0: {} + + stop-iteration-iterator@1.0.0: + dependencies: + internal-slot: 1.0.7 + + stream-combiner@0.0.4: + dependencies: + duplexer: 0.1.2 + + streamsearch@1.1.0: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string.prototype.includes@2.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.matchall@4.0.11: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.2 + set-function-name: 2.0.2 + side-channel: 1.0.6 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + optionalDependencies: + '@babel/core': 7.26.10 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + swr@2.2.5(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + use-sync-external-store: 1.2.2(react@19.1.0) + + symbol-tree@3.2.4: {} + + tabbable@6.2.0: {} + + tailwindcss@3.4.14: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-import: 15.1.0(postcss@8.5.3) + postcss-js: 4.0.1(postcss@8.5.3) + postcss-load-config: 4.0.2(postcss@8.5.3) + postcss-nested: 6.2.0(postcss@8.5.3) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tailwindcss@4.1.4: {} + + tapable@2.2.1: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + term-size@2.2.1: {} + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thirty-two@1.0.2: {} + + throttleit@1.0.1: {} + + through@2.3.8: {} + + tinybench@2.9.0: {} + + tinycolor2@1.4.2: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.0.2: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toggle-selection@1.0.6: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-api-utils@1.4.1(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + ts-error@1.0.6: {} + + ts-interface-checker@0.1.13: {} + + ts-poet@6.11.0: + dependencies: + dprint-node: 1.0.8 + + ts-proto-descriptors@2.0.0: + dependencies: + '@bufbuild/protobuf': 2.2.5 + + ts-proto@2.7.0: + dependencies: + '@bufbuild/protobuf': 2.2.5 + case-anything: 2.1.13 + ts-poet: 6.11.0 + ts-proto-descriptors: 2.0.0 + + tsconfck@3.1.5(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsup@8.4.0(jiti@1.21.6)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.7.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.0(supports-color@5.5.0) + esbuild: 0.25.2 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.5.3)(yaml@2.7.1) + resolve-from: 5.0.0 + rollup: 4.40.0 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.3 + typescript: 5.8.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turbo-darwin-64@2.5.0: + optional: true + + turbo-darwin-arm64@2.5.0: + optional: true + + turbo-linux-64@2.5.0: + optional: true + + turbo-linux-arm64@2.5.0: + optional: true + + turbo-windows-64@2.5.0: + optional: true + + turbo-windows-arm64@2.5.0: + optional: true + + turbo@2.5.0: + optionalDependencies: + turbo-darwin-64: 2.5.0 + turbo-darwin-arm64: 2.5.0 + turbo-linux-64: 2.5.0 + turbo-linux-arm64: 2.5.0 + turbo-windows-64: 2.5.0 + turbo-windows-arm64: 2.5.0 + + tweetnacl@0.14.5: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + + typescript@5.8.3: {} + + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + + undefsafe@2.0.5: {} + + undici-types@6.21.0: {} + + unicorn-magic@0.1.0: {} + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + untildify@4.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-intl@3.25.1(react@19.1.0): + dependencies: + '@formatjs/fast-memoize': 2.2.3 + intl-messageformat: 10.7.7 + react: 19.1.0 + + use-sync-external-store@1.2.2(react@19.1.0): + dependencies: + react: 19.1.0 + + util-deprecate@1.0.2: {} + + uuid@11.1.0: {} + + uuid@8.3.2: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + vite-node@3.1.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1): + dependencies: + cac: 6.7.14 + debug: 4.4.0(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)): + dependencies: + debug: 4.4.0(supports-color@5.5.0) + globrex: 0.1.2 + tsconfck: 3.1.5(typescript@5.8.3) + optionalDependencies: + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - supports-color + - typescript + + vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1): + dependencies: + esbuild: 0.25.2 + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.3 + rollup: 4.40.0 + tinyglobby: 0.2.13 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + jiti: 1.21.6 + sass: 1.87.0 + yaml: 2.7.1 + + vitest@3.1.2(@types/node@22.14.1)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.87.0)(yaml@2.7.1): + dependencies: + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + '@vitest/pretty-format': 3.1.2 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.2.0 + debug: 4.4.0(supports-color@5.5.0) + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + vite-node: 3.1.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.14.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wait-on@8.0.3(debug@4.4.0): + dependencies: + axios: 1.8.4(debug@4.4.0) + joi: 17.13.3 + lodash: 4.17.21 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + + which-builtin-type@1.1.4: + dependencies: + function.prototype.name: 1.1.6 + has-tostringtag: 1.0.2 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.1.4 + is-weakref: 1.0.2 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.3 + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.18.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.7.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} diff --git a/login/pnpm-workspace.yaml b/login/pnpm-workspace.yaml new file mode 100644 index 0000000000..3ff5faaaf5 --- /dev/null +++ b/login/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/login/turbo.json b/login/turbo.json new file mode 100644 index 0000000000..d26bdd8f56 --- /dev/null +++ b/login/turbo.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "globalDependencies": ["**/.env.*local"], + "globalEnv": [ + "DEBUG", + "VERCEL_URL", + "EMAIL_VERIFICATION", + "AUDIENCE", + "SYSTEM_USER_ID", + "SYSTEM_USER_PRIVATE_KEY", + "ZITADEL_API_URL", + "ZITADEL_SERVICE_USER_TOKEN", + "NEXT_PUBLIC_BASE_PATH", + "CUSTOM_REQUEST_HEADERS", + "NODE_ENV" + ], + "tasks": { + "generate": { + "cache": true + }, + "build": {}, + "build:standalone": {}, + "test": {}, + "start": {}, + "start:built": {}, + "test:unit": {}, + "test:integration": {}, + "test:watch": { + "persistent": true + }, + "lint": {}, + "lint:fix": {}, + "dev": { + "cache": false, + "persistent": true + }, + "clean": { + "cache": false + } + } +}