mirror of
https://github.com/zitadel/zitadel.git
synced 2025-02-25 18:55:27 -06:00
Merge branch 'main' into dependabot/npm_and_yarn/docs/follow-redirects-1.15.6
This commit is contained in:
commit
fc3ab7d8cc
@ -5,7 +5,7 @@
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.21"
|
||||
"version": "1.22"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "18"
|
||||
|
@ -7,7 +7,17 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
network_mode: service:db
|
||||
command: sleep infinity
|
||||
|
||||
environment:
|
||||
- 'ZITADEL_DATABASE_POSTGRES_HOST=db'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_PORT=5432'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=postgres'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=postgres'
|
||||
- 'ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable'
|
||||
- 'ZITADEL_EXTERNALSECURE=false'
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
|
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
40
.github/pull_request_template.md
vendored
40
.github/pull_request_template.md
vendored
@ -1,15 +1,27 @@
|
||||
### Definition of Ready
|
||||
# Which Problems Are Solved
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] No debug or dead code
|
||||
- [ ] My code has no repetitions
|
||||
- [ ] Critical parts are tested automatically
|
||||
- [ ] Where possible E2E tests are implemented
|
||||
- [ ] Documentation/examples are up-to-date
|
||||
- [ ] All non-functional requirements are met
|
||||
- [ ] Functionality of the acceptance criteria is checked manually on the dev system.
|
||||
Replace this example text with a concise list of problems that this PR solves.
|
||||
For example:
|
||||
- If the property XY is not given, the system crashes with a nil pointer exception.
|
||||
|
||||
# How the Problems Are Solved
|
||||
|
||||
Replace this example text with a concise list of changes that this PR introduces.
|
||||
For example:
|
||||
- Validates if property XY is given and throws an error if not
|
||||
|
||||
# Additional Changes
|
||||
|
||||
Replace this example text with a concise list of additional changes that this PR introduces, that are not directly solving the initial problem but are related.
|
||||
For example:
|
||||
- The docs explicitly describe that the property XY is mandatory
|
||||
- Adds missing translations for validations.
|
||||
|
||||
# Additional Context
|
||||
|
||||
Replace this example with links to related issues, discussions, discord threads, or other sources with more context.
|
||||
Use the Closing #issue syntax for issues that are resolved with this PR.
|
||||
- Closes #123
|
||||
- Discussion #456
|
||||
- Follow-up for PR #789
|
||||
- https://discord.com/channels/123/456
|
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@ -1,9 +1,18 @@
|
||||
name: ZITADEL CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- "*"
|
||||
branches:
|
||||
- "main"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@ -16,7 +25,7 @@ jobs:
|
||||
with:
|
||||
node_version: "20"
|
||||
buf_version: "latest"
|
||||
go_version: "1.21"
|
||||
go_version: "1.22"
|
||||
|
||||
console:
|
||||
uses: ./.github/workflows/console.yml
|
||||
@ -27,14 +36,14 @@ jobs:
|
||||
version:
|
||||
uses: ./.github/workflows/version.yml
|
||||
with:
|
||||
semantic_version: "19.0.2"
|
||||
semantic_version: "23.0.7"
|
||||
dry_run: true
|
||||
|
||||
compile:
|
||||
needs: [core, console, version]
|
||||
uses: ./.github/workflows/compile.yml
|
||||
with:
|
||||
go_version: "1.21"
|
||||
go_version: "1.22"
|
||||
core_cache_key: ${{ needs.core.outputs.cache_key }}
|
||||
console_cache_key: ${{ needs.console.outputs.cache_key }}
|
||||
core_cache_path: ${{ needs.core.outputs.cache_path }}
|
||||
@ -45,15 +54,17 @@ jobs:
|
||||
needs: core
|
||||
uses: ./.github/workflows/core-test.yml
|
||||
with:
|
||||
go_version: "1.21"
|
||||
go_version: "1.22"
|
||||
core_cache_key: ${{ needs.core.outputs.cache_key }}
|
||||
core_cache_path: ${{ needs.core.outputs.cache_path }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
lint:
|
||||
needs: [core, console]
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
go_version: "1.21"
|
||||
go_version: "1.22"
|
||||
node_version: "18"
|
||||
buf_version: "latest"
|
||||
go_lint_version: "v1.55.2"
|
||||
@ -86,8 +97,10 @@ jobs:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
secrets:
|
||||
GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }}
|
||||
APP_ID: ${{ secrets.APP_ID }}
|
||||
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
with:
|
||||
build_image_name: ${{ needs.container.outputs.build_image }}
|
||||
semantic_version: "19.0.2"
|
||||
semantic_version: "23.0.7"
|
||||
image_name: "ghcr.io/zitadel/zitadel"
|
||||
google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel"
|
||||
|
3
.github/workflows/codeql.yml
vendored
3
.github/workflows/codeql.yml
vendored
@ -13,6 +13,9 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
|
7
.github/workflows/core-test.yml
vendored
7
.github/workflows/core-test.yml
vendored
@ -12,6 +12,9 @@ on:
|
||||
core_cache_path:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
postgres:
|
||||
@ -76,7 +79,7 @@ jobs:
|
||||
run: make core_integration_test
|
||||
-
|
||||
name: publish coverage
|
||||
uses: codecov/codecov-action@v4.1.0
|
||||
uses: codecov/codecov-action@v4.3.0
|
||||
with:
|
||||
file: profile.cov
|
||||
name: core-integration-tests-postgres
|
||||
@ -145,7 +148,7 @@ jobs:
|
||||
# run: make core_integration_test
|
||||
# -
|
||||
# name: publish coverage
|
||||
# uses: codecov/codecov-action@v4.1.0
|
||||
# uses: codecov/codecov-action@v4.3.0
|
||||
# with:
|
||||
# file: profile.cov
|
||||
# name: core-integration-tests-cockroach
|
||||
|
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
@ -5,6 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
74
.github/workflows/homebrew.yml
vendored
74
.github/workflows/homebrew.yml
vendored
@ -1,74 +0,0 @@
|
||||
name: ZITADEL Update Homebrew Formula
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
homebrew-releaser:
|
||||
runs-on: ubuntu-latest
|
||||
name: homebrew-releaser
|
||||
steps:
|
||||
- name: Release my project to my Homebrew tap
|
||||
uses: Justintime50/homebrew-releaser@v1
|
||||
with:
|
||||
# The name of the homebrew tap to publish your formula to as it appears on GitHub.
|
||||
# Required - strings
|
||||
homebrew_owner: zitadel
|
||||
homebrew_tap: homebrew-tap
|
||||
|
||||
# The name of the folder in your homebrew tap where formula will be committed to.
|
||||
# Default is shown - string
|
||||
formula_folder: Formula
|
||||
|
||||
# The Personal Access Token (saved as a repo secret) that has `repo` permissions for the repo running the action AND Homebrew tap you want to release to.
|
||||
# Required - string
|
||||
github_token: ${{ secrets.CR_PAT }}
|
||||
|
||||
# Git author info used to commit to the homebrew tap.
|
||||
# Defaults are shown - strings
|
||||
commit_owner: homebrew-releaser
|
||||
commit_email: homebrew-releaser@zitadel.com
|
||||
|
||||
# Custom dependencies in case other formulas are needed to build the current one.
|
||||
# Optional - multiline string
|
||||
depends_on: |
|
||||
"go" => :optional
|
||||
"git"
|
||||
|
||||
# Custom install command for your formula.
|
||||
# Required - string
|
||||
install: 'bin.install "zitadel"'
|
||||
|
||||
# Custom test command for your formula so you can run `brew test`.
|
||||
# Optional - string
|
||||
test: 'ystem "#{bin}/zitadel -v"'
|
||||
|
||||
# Allows you to set a custom download strategy. Note that you'll need
|
||||
# to implement the strategy and add it to your tap repository.
|
||||
# Example: https://docs.brew.sh/Formula-Cookbook#specifying-the-download-strategy-explicitly
|
||||
# Optional - string
|
||||
# download_strategy: CurlDownloadStrategy
|
||||
|
||||
# Allows you to add a custom require_relative at the top of the formula template.
|
||||
# Optional - string
|
||||
# custom_require: custom_download_strategy
|
||||
|
||||
# Adds URL and checksum targets for different OS and architecture pairs. Using this option assumes
|
||||
# a tar archive exists on your GitHub repo with the following URL pattern (this cannot be customized):
|
||||
# https://github.com/{GITHUB_OWNER}/{REPO_NAME}/releases/download/{TAG}/{REPO_NAME}-{VERSION}-{OPERATING_SYSTEM}-{ARCHITECTURE}.tar.gz'
|
||||
# Darwin AMD pre-existing path example: https://github.com/justintime50/myrepo/releases/download/v1.2.0/myrepo-1.2.0-darwin-amd64.tar.gz
|
||||
# Linux ARM pre-existing path example: https://github.com/justintime50/myrepo/releases/download/v1.2.0/myrepo-1.2.0-linux-arm64.tar.gz
|
||||
# Optional - booleans
|
||||
target_darwin_amd64: true
|
||||
target_darwin_arm64: true
|
||||
target_linux_amd64: true
|
||||
target_linux_arm64: true
|
||||
|
||||
# Skips committing the generated formula to a homebrew tap (useful for local testing).
|
||||
# Default is shown - boolean
|
||||
skip_commit: true
|
||||
|
||||
# Logs debugging info to console.
|
||||
# Default is shown - boolean
|
||||
debug: true
|
4
.github/workflows/issues.yml
vendored
4
.github/workflows/issues.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: add issue
|
||||
uses: actions/add-to-project@v0.6.0
|
||||
uses: actions/add-to-project@v1.0.1
|
||||
if: ${{ github.event_name == 'issues' }}
|
||||
with:
|
||||
# You can target a repository in a different organization
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
- name: add pr
|
||||
uses: actions/add-to-project@v0.6.0
|
||||
uses: actions/add-to-project@v1.0.1
|
||||
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
|
||||
|
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@ -95,10 +95,8 @@ jobs:
|
||||
key: ${{ inputs.core_cache_key }}
|
||||
fail-on-cache-miss: true
|
||||
-
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: ${{ inputs.go_lint_version }}
|
||||
github-token: ${{ github.token }}
|
||||
only-new-issues: true
|
||||
skip-pkg-cache: true
|
||||
skip-build-cache: true
|
||||
|
31
.github/workflows/ready_for_review.yml
vendored
Normal file
31
.github/workflows/ready_for_review.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const content = `### Thanks for your contribution @${{ github.event.pull_request.user.login }}! 🎉
|
||||
|
||||
Please make sure you tick the following checkboxes before marking this Pull Request (PR) as ready for review:
|
||||
|
||||
- [ ] I am happy with the code
|
||||
- [ ] Documentations and examples are up-to-date
|
||||
- [ ] Logical behavior changes are tested automatically
|
||||
- [ ] No debug or dead code
|
||||
- [ ] My code has no repetitions
|
||||
- [ ] The PR title adheres to the [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] The example texts in the PR description are replaced.
|
||||
- [ ] If there are any open TODOs or follow-ups, they are described in issues and link to this PR
|
||||
- [ ] If there are deviations from a user stories acceptance criteria or design, they are agreed upon with the PO and documented.
|
||||
`;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: content
|
||||
})
|
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
@ -19,6 +19,12 @@ on:
|
||||
GCR_JSON_KEY_BASE64:
|
||||
description: 'base64 endcrypted key to connect to Google'
|
||||
required: true
|
||||
APP_ID:
|
||||
description: 'GH App ID to request token for homebrew update'
|
||||
required: true
|
||||
APP_PRIVATE_KEY:
|
||||
description: 'GH App Private Key to request token for homebrew update'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@ -27,6 +33,33 @@ jobs:
|
||||
semantic_version: ${{ inputs.semantic_version }}
|
||||
dry_run: false
|
||||
|
||||
# TODO: remove the publish job and publish releases directly with the @semantic-release/github plugin (remove draftRelease: true)
|
||||
# as soon as it supports configuring the create release payload property make_latest to "legacy"
|
||||
# https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release--parameters
|
||||
publish:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [ version ]
|
||||
steps:
|
||||
- id: get_release
|
||||
uses: cardinalby/git-get-release-action@v1
|
||||
with:
|
||||
commitSha: ${{ github.sha }}
|
||||
draft: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish Release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: ${{ steps.get_release.outputs.id }},
|
||||
draft: false,
|
||||
make_latest: "legacy"
|
||||
});
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [ version ]
|
||||
@ -73,3 +106,23 @@ jobs:
|
||||
docker buildx imagetools create \
|
||||
--tag ${{ inputs.image_name }}:latest-debug \
|
||||
${{ inputs.build_image_name }}-debug
|
||||
|
||||
homebrew-tap:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: version
|
||||
if: ${{ github.ref_name == 'next' }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: generate token
|
||||
uses: tibdex/github-app-token@v2
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.APP_ID }}
|
||||
private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Trigger Homebrew
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
run: |
|
||||
gh workflow -R zitadel/homebrew-tap run update.yml -f runId=${RUN_ID} -f version=${VERSION}
|
||||
|
1
.github/workflows/version.yml
vendored
1
.github/workflows/version.yml
vendored
@ -43,6 +43,7 @@ jobs:
|
||||
semantic_version: ${{ inputs.semantic_version }}
|
||||
extra_plugins: |
|
||||
@semantic-release/exec@6.0.3
|
||||
@semantic-release/github@10.0.2
|
||||
-
|
||||
name: output
|
||||
id: output
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -82,3 +82,8 @@ go.work
|
||||
go.work.sum
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
load-test/node_modules
|
||||
load-test/yarn-error.log
|
||||
load-test/dist
|
||||
.vercel
|
||||
|
@ -8,7 +8,7 @@ issues:
|
||||
run:
|
||||
concurrency: 4
|
||||
timeout: 10m
|
||||
go: '1.21'
|
||||
go: '1.22'
|
||||
skip-dirs:
|
||||
- .artifacts
|
||||
- .backups
|
||||
|
@ -9,6 +9,7 @@ module.exports = {
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
draftRelease: true,
|
||||
assets: [
|
||||
{
|
||||
path: ".artifacts/zitadel-linux-amd64/zitadel-linux-amd64.tar.gz",
|
||||
|
@ -108,13 +108,13 @@ Please make sure you cover your changes with tests before marking a Pull Request
|
||||
|
||||
The code consists of the following parts:
|
||||
|
||||
| name | description | language | where to find |
|
||||
| --------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| backend | Service that serves the grpc(-web) and RESTful API | [go](https://go.dev) | [API implementation](./internal/api/grpc) |
|
||||
| console | Frontend the user interacts with after log in | [Angular](https://angular.io), [Typescript](https://www.typescriptlang.org) | [./console](./console) |
|
||||
| name | description | language | where to find |
|
||||
| --------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| backend | Service that serves the grpc(-web) and RESTful API | [go](https://go.dev) | [API implementation](./internal/api/grpc) |
|
||||
| console | Frontend the user interacts with after log in | [Angular](https://angular.io), [Typescript](https://www.typescriptlang.org) | [./console](./console) |
|
||||
| login | Server side rendered frontend the user interacts with during login | [go](https://go.dev), [go templates](https://pkg.go.dev/html/template) | [./internal/api/ui/login](./internal/api/ui/login) |
|
||||
| API definitions | Specifications of the API | [Protobuf](https://developers.google.com/protocol-buffers) | [./proto/zitadel](./proto/zitadel) |
|
||||
| docs | Project documentation made with docusaurus | [Docusaurus](https://docusaurus.io/) | [./docs](./docs) |
|
||||
| API definitions | Specifications of the API | [Protobuf](https://developers.google.com/protocol-buffers) | [./proto/zitadel](./proto/zitadel) |
|
||||
| docs | Project documentation made with docusaurus | [Docusaurus](https://docusaurus.io/) | [./docs](./docs) |
|
||||
|
||||
Please validate and test the code before you contribute.
|
||||
|
||||
@ -129,18 +129,32 @@ We add the label "good first issue" for problems we think are a good starting po
|
||||
|
||||
We are committed to creating a welcoming and inclusive community for all developers, regardless of their gender identity or expression. To achieve this, we are actively working to ensure that our contribution guidelines are gender-neutral and use inclusive language.
|
||||
|
||||
**Use gender-neutral pronouns**:
|
||||
**Use gender-neutral pronouns**:
|
||||
Don't use gender-specific pronouns unless the person you're referring to is actually that gender.
|
||||
In particular, don't use he, him, his, she, or her as gender-neutral pronouns, and don't use he/she or (s)he or other such punctuational approaches. Instead, use the singular they.
|
||||
|
||||
**Choose gender-neutral alternatives**:
|
||||
Opt for gender-neutral terms instead of gendered ones whenever possible.
|
||||
**Choose gender-neutral alternatives**:
|
||||
Opt for gender-neutral terms instead of gendered ones whenever possible.
|
||||
Replace "policeman" with "police officer," "manpower" with "workforce," and "businessman" with "entrepreneur" or "businessperson."
|
||||
|
||||
**Avoid ableist language**:
|
||||
Ableist language includes words or phrases such as crazy, insane, blind to or blind eye to, cripple, dumb, and others.
|
||||
Choose alternative words depending on the context.
|
||||
|
||||
### Developing ZITADEL with Dev Containers
|
||||
|
||||
Follow the instructions provided by your code editor/IDE to initiate the development container. This typically involves opening the "Command Palette" or similar functionality and searching for commands related to "Dev Containers" or "Remote Containers". The quick start guide for VS Code can found [here](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container)
|
||||
|
||||
When you are connected to the container run the following commands to start ZITADEL.
|
||||
|
||||
```bash
|
||||
make compile && ./zitadel start-from-init --masterkey MasterkeyNeedsToHave32Characters --tlsMode disabled
|
||||
```
|
||||
|
||||
ZITADEL serves traffic as soon as you can see the following log line:
|
||||
|
||||
`INFO[0001] server is listening on [::]:8080`
|
||||
|
||||
### Backend/login
|
||||
|
||||
By executing the commands from this section, you run everything you need to develop the ZITADEL backend locally.
|
||||
@ -154,7 +168,7 @@ ZITADEL uses [golangci-lint](https://golangci-lint.run) for code quality checks.
|
||||
The commands in this section are tested against the following software versions:
|
||||
|
||||
- [Docker version 20.10.17](https://docs.docker.com/engine/install/)
|
||||
- [Go version 1.21](https://go.dev/doc/install)
|
||||
- [Go version 1.22](https://go.dev/doc/install)
|
||||
- [Delve 1.9.1](https://github.com/go-delve/delve/tree/v1.9.1/Documentation/installation)
|
||||
|
||||
Make some changes to the source code, then run the database locally.
|
||||
@ -194,7 +208,7 @@ make core_unit_test
|
||||
To test the database-connected gRPC API, run PostgreSQL and CockroachDB, set up a ZITADEL instance and run the tests including integration tests:
|
||||
|
||||
```bash
|
||||
export INTEGRATION_DB_FLAVOR="postgres" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters"
|
||||
export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters"
|
||||
docker compose -f internal/integration/config/docker-compose.yaml up --pull always --wait ${INTEGRATION_DB_FLAVOR}
|
||||
make core_integration_test
|
||||
docker compose -f internal/integration/config/docker-compose.yaml down
|
||||
@ -398,6 +412,13 @@ ZITADEL loads translations from four files:
|
||||
|
||||
You may edit the texts in these files or create a new file for additional language support. Make sure you set the locale (ISO 639-1 code) as the name of the new language file.
|
||||
Please make sure that the languages within the files remain in their own language, e.g. German must always be `Deutsch.
|
||||
If you have added support for a new language, please also ensure that it is added in the list of languages in all the other language files.
|
||||
|
||||
You also have to add some changes to the following files:
|
||||
- [Register Local File](./console/src/app/app.module.ts)
|
||||
- [Add Supported Language](./console/src/app/utils/language.ts)
|
||||
- [Customized Text Docs](./docs/docs/guides/manage/customize/texts.md)
|
||||
- [Add language option](./internal/api/ui/login/static/templates/external_not_found_option.html)
|
||||
|
||||
## Want to start ZITADEL?
|
||||
|
||||
|
63
MEETING_SCHEDULE.md
Normal file
63
MEETING_SCHEDULE.md
Normal file
@ -0,0 +1,63 @@
|
||||
# ZITADEL Office Hours
|
||||
|
||||
Dear community!
|
||||
We're excited to announce bi-weekly office hours.
|
||||
|
||||
## #2 New Resources and Settings APIs
|
||||
|
||||
**Shape the future of ZITADEL Let's redesign the API for a better developer experience!**
|
||||
|
||||
Dear community,
|
||||
|
||||
Following the great success of our first office hours, we're back for round two! This time, we're focusing on YOU and how we can build the best possible ZITADEL API together.
|
||||
|
||||
We've been working on some ideas for the API, and we're excited to share them with you during the session. But more importantly, we want to hear YOUR thoughts! What does your dream ZITADEL API look like? What improvements would make your development life easier?
|
||||
|
||||
Join the open discussion next Wednesday in the office hours voice channel on Discord. We're ready for your honest feedback and fresh perspectives that help us shape the future of ZITADEL!
|
||||
|
||||
**What to expect**:
|
||||
|
||||
* **Our Suggestions**: @eliobischof will walk you through the improvement suggestions.
|
||||
* **Open Discussion**: Get your questions answered directly by the ZITADEL team, describe your pain points and drop your thoughts in an
|
||||
open discussion.
|
||||
|
||||
**Details**:
|
||||
|
||||
* **Target Audience**: Developers and IT Ops personnel using ZITADEL
|
||||
* **Topic**: API Redesign and Q&A
|
||||
* **When**: Wednesday 12th of June 12 pm PST / 3 pm EST / 9 pm CEST
|
||||
* **Duration**: about 1 hour
|
||||
* **Platform**: ZITADEL Discord Server (Join us here: https://zitadel.com/office-hours?event=1248016231936692274 )
|
||||
|
||||
**In the meantime**:
|
||||
|
||||
KUDOS, if you already [have a look at our proposal](https://zitadel.com/docs/apis/v3) before the start of the event. Share any inputs in the chat of the [office hours channel](https://zitadel.com/office-hours) on our Discord server.
|
||||
|
||||
We look forward to seeing you there!
|
||||
|
||||
P.S. Spread the word! Share this announcement with your fellow ZITADEL users who might be interested 📢
|
||||
|
||||
## #1 Dive Deep into Actions v2
|
||||
|
||||
The first office hour is dedicated to exploring the [new Actions v2 feature](https://zitadel.com/docs/concepts/features/actions_v2).
|
||||
|
||||
What to expect:
|
||||
|
||||
* **Deep Dive**: @adlerhurst will walk you through the functionalities and benefits of Actions v2.
|
||||
* **Live Q&A**: Get your questions answered directly by the ZITADEL team during the dedicated Q&A session.
|
||||
|
||||
Details:
|
||||
|
||||
* **Target Audience**: Developers and IT Ops personnel using ZITADEL
|
||||
* **Topic**: Actions v2 deep dive and Q&A
|
||||
* **When**: Wednesday 29th of May 12 pm PST / 3 pm EST / 9 pm CEST
|
||||
* **Duration**: about 1 hour
|
||||
* **Platform**: Zitadel Discord Server (Join us here: https://zitadel.com/office-hours?event=1243282884677341275 )
|
||||
|
||||
In the meantime:
|
||||
|
||||
Feel free to share any questions you already have about Actions v2 in the chat of the [office hours channel](https://zitadel.com/office-hours) on our Discord server.
|
||||
|
||||
We look forward to seeing you there!
|
||||
|
||||
P.S. Spread the word! Share this announcement with your fellow ZITADEL users who might be interested.
|
20
Makefile
20
Makefile
@ -33,10 +33,10 @@ core_static:
|
||||
|
||||
.PHONY: core_generate_all
|
||||
core_generate_all:
|
||||
go install github.com/dmarkham/enumer@v1.5.9 # https://pkg.go.dev/github.com/dmarkham/enumer?tab=versions
|
||||
go install github.com/dmarkham/enumer@v1.5.10 # https://pkg.go.dev/github.com/dmarkham/enumer?tab=versions
|
||||
go install github.com/rakyll/statik@v0.1.7 # https://pkg.go.dev/github.com/rakyll/statik?tab=versions
|
||||
go install go.uber.org/mock/mockgen@v0.4.0 # https://pkg.go.dev/go.uber.org/mock/mockgen?tab=versions
|
||||
go install golang.org/x/tools/cmd/stringer@v0.17.0 # https://pkg.go.dev/golang.org/x/tools/cmd/stringer?tab=versions
|
||||
go install golang.org/x/tools/cmd/stringer@v0.22.0 # https://pkg.go.dev/golang.org/x/tools/cmd/stringer?tab=versions
|
||||
go generate ./...
|
||||
|
||||
.PHONY: core_assets
|
||||
@ -57,12 +57,12 @@ endif
|
||||
|
||||
.PHONY: core_grpc_dependencies
|
||||
core_grpc_dependencies:
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.32 # https://pkg.go.dev/google.golang.org/protobuf/cmd/protoc-gen-go?tab=versions
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3 # https://pkg.go.dev/google.golang.org/grpc/cmd/protoc-gen-go-grpc?tab=versions
|
||||
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.19.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway?tab=versions
|
||||
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.19.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2?tab=versions
|
||||
go install github.com/envoyproxy/protoc-gen-validate@v1.0.3 # https://pkg.go.dev/github.com/envoyproxy/protoc-gen-validate?tab=versions
|
||||
go install github.com/bufbuild/buf/cmd/buf@v1.28.1 # https://pkg.go.dev/github.com/bufbuild/buf/cmd/buf?tab=versions
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2 # https://pkg.go.dev/google.golang.org/protobuf/cmd/protoc-gen-go?tab=versions
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.4 # https://pkg.go.dev/google.golang.org/grpc/cmd/protoc-gen-go-grpc?tab=versions
|
||||
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.20.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway?tab=versions
|
||||
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.20.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2?tab=versions
|
||||
go install github.com/envoyproxy/protoc-gen-validate@v1.0.4 # https://pkg.go.dev/github.com/envoyproxy/protoc-gen-validate?tab=versions
|
||||
go install github.com/bufbuild/buf/cmd/buf@v1.34.0 # https://pkg.go.dev/github.com/bufbuild/buf/cmd/buf?tab=versions
|
||||
|
||||
.PHONY: core_api
|
||||
core_api: core_api_generator core_grpc_dependencies
|
||||
@ -115,6 +115,10 @@ core_integration_setup:
|
||||
core_integration_test: core_integration_setup
|
||||
go test -tags=integration -race -p 1 -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./...
|
||||
|
||||
.PHONY: core_integration_test_fast
|
||||
core_integration_test_fast: core_integration_setup
|
||||
go test -tags=integration -p 1 ./...
|
||||
|
||||
.PHONY: console_lint
|
||||
console_lint:
|
||||
cd console && \
|
||||
|
18
README.md
18
README.md
@ -30,6 +30,9 @@
|
||||
<img src="./docs/static/logos/oidc-cert.png" /></a>
|
||||
</p>
|
||||
|
||||
|Community Meeting|
|
||||
|------------------|
|
||||
|ZITADEL holds bi-weekly community calls. To join the community calls or to watch previous meeting notes and recordings, please visit the [meeting schedule](https://github.com/zitadel/zitadel/blob/main/MEETING_SCHEDULE.md).|
|
||||
|
||||
Are you searching for a user management tool that is quickly set up like Auth0 and open source like Keycloak?
|
||||
|
||||
@ -96,7 +99,7 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade
|
||||
|
||||
- [API-first approach](https://zitadel.com/docs/apis/introduction)
|
||||
- [Multi-tenancy](https://zitadel.com/docs/guides/solution-scenarios/b2b) authentication and access management
|
||||
- Strong audit trail thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern
|
||||
- [Strong audit trail](https://zitadel.com/docs/concepts/features/audit-trail) thanks to [event sourcing](https://zitadel.com/docs/concepts/eventstore/overview) as storage pattern
|
||||
- [Actions](https://zitadel.com/docs/apis/actions/introduction) to react on events with custom code and extended ZITADEL for you needs
|
||||
- [Branding](https://zitadel.com/docs/guides/manage/customize/branding) for a uniform user experience across multiple organizations
|
||||
- [Self-service](https://zitadel.com/docs/concepts/features/selfservice) for end-users, business customers, and administrators
|
||||
@ -107,16 +110,17 @@ Yet it offers everything you need for a customer identity ([CIAM](https://zitade
|
||||
Authentication
|
||||
|
||||
- Single Sign On (SSO)
|
||||
- Passkeys support (FIDO2 / WebAuthN)
|
||||
- [Passkeys support (FIDO2 / WebAuthN)](https://zitadel.com/docs/concepts/features/passkeys)
|
||||
- Username / Password
|
||||
- Multifactor authentication with OTP, U2F, Email OTP, SMS OTP
|
||||
- LDAP
|
||||
- External enterprise identity providers and social logins
|
||||
- [LDAP](https://zitadel.com/docs/guides/integrate/identity-providers/ldap)
|
||||
- [External enterprise identity providers and social logins](https://zitadel.com/docs/guides/integrate/identity-providers/introduction)
|
||||
- [Device authorization](https://zitadel.com/docs/guides/solution-scenarios/device-authorization)
|
||||
- [OpenID Connect certified](https://openid.net/certification/#OPs) => [OIDC Endpoints](https://zitadel.com/docs/apis/openidoauth/endpoints)
|
||||
- [SAML 2.0](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) => [SAML Endpoints](https://zitadel.com/docs/apis/saml/endpoints)
|
||||
- [Custom sessions](https://zitadel.com/docs/guides/integrate/login-ui/username-password) if you need to go beyond OIDC or SAML
|
||||
- [Machine-to-machine](https://zitadel.com/docs/guides/integrate/serviceusers) with JWT profile, Personal Access Tokens (PAT), and Client Credentials
|
||||
- [Machine-to-machine](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users) with JWT profile, Personal Access Tokens (PAT), and Client Credentials
|
||||
- [Token exchange and impersonation](https://zitadel.com/docs/guides/integrate/token-exchange)
|
||||
|
||||
Multi-Tenancy
|
||||
|
||||
@ -130,6 +134,10 @@ Integration
|
||||
- [GRPC and REST APIs](https://zitadel.com/docs/apis/introduction) for every functionality and resource
|
||||
- [Actions](https://zitadel.com/docs/apis/actions/introduction) to call any API, send webhooks, adjust workflows, or customize tokens
|
||||
- [Role Based Access Control (RBAC)](https://zitadel.com/docs/guides/integrate/retrieve-user-roles)
|
||||
- [Examples and SDKs](https://zitadel.com/docs/sdk-examples/introduction)
|
||||
- [Audit Log and SOC/SIEM](https://zitadel.com/docs/guides/integrate/external-audit-log)
|
||||
- [User registration and onboarding](https://zitadel.com/docs/guides/integrate/onboarding)
|
||||
- [Hosted and custom login user interface](https://zitadel.com/docs/guides/integrate/login-ui)
|
||||
|
||||
Self-Service
|
||||
- [Self-registration](https://zitadel.com/docs/concepts/features/selfservice#registration) including verification
|
||||
|
File diff suppressed because one or more lines are too long
@ -3,7 +3,8 @@ package initialise
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
)
|
||||
|
||||
|
@ -19,6 +19,7 @@ var (
|
||||
|
||||
createUserStmt string
|
||||
grantStmt string
|
||||
settingsStmt string
|
||||
databaseStmt string
|
||||
createEventstoreStmt string
|
||||
createProjectionsStmt string
|
||||
@ -39,7 +40,7 @@ func New() *cobra.Command {
|
||||
Long: `Sets up the minimum requirements to start ZITADEL.
|
||||
|
||||
Prerequisites:
|
||||
- cockroachDB
|
||||
- database (PostgreSql or cockroachdb)
|
||||
|
||||
The user provided by flags needs privileges to
|
||||
- create the database if it does not exist
|
||||
@ -53,7 +54,7 @@ The user provided by flags needs privileges to
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newZitadel(), newDatabase(), newUser(), newGrant())
|
||||
cmd.AddCommand(newZitadel(), newDatabase(), newUser(), newGrant(), newSettings())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -62,6 +63,7 @@ func InitAll(ctx context.Context, config *Config) {
|
||||
VerifyUser(config.Database.Username(), config.Database.Password()),
|
||||
VerifyDatabase(config.Database.DatabaseName()),
|
||||
VerifyGrant(config.Database.DatabaseName(), config.Database.Username()),
|
||||
VerifySettings(config.Database.DatabaseName(), config.Database.Username()),
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to initialize the database")
|
||||
|
||||
@ -147,6 +149,11 @@ func ReadStmts(typ string) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsStmt, err = readStmt(typ, "11_settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
db_mock "github.com/zitadel/zitadel/internal/database/mock"
|
||||
)
|
||||
|
||||
type db struct {
|
||||
@ -16,7 +18,7 @@ type db struct {
|
||||
|
||||
func prepareDB(t *testing.T, expectations ...expectation) db {
|
||||
t.Helper()
|
||||
client, mock, err := sqlmock.New()
|
||||
client, mock, err := sqlmock.New(sqlmock.ValueConverterOption(new(db_mock.TypeConverter)))
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create sql mock: %v", err)
|
||||
}
|
||||
@ -44,7 +46,7 @@ func expectExec(stmt string, err error, args ...driver.Value) expectation {
|
||||
|
||||
func expectQuery(stmt string, err error, columns []string, rows [][]driver.Value, args ...driver.Value) expectation {
|
||||
return func(m sqlmock.Sqlmock) {
|
||||
res := sqlmock.NewRows(columns)
|
||||
res := m.NewRows(columns)
|
||||
for _, row := range rows {
|
||||
res.AddRow(row...)
|
||||
}
|
||||
|
4
cmd/initialise/sql/cockroach/11_settings.sql
Normal file
4
cmd/initialise/sql/cockroach/11_settings.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- replace the first %[1]q with the database in double quotes
|
||||
-- replace the second \%[2]q with the user in double quotes$
|
||||
-- For more information see technical advisory 10009 (https://zitadel.com/docs/support/advisory/a10009)
|
||||
ALTER ROLE %[2]q IN DATABASE %[1]q SET enable_durable_locking_for_serializable = on;
|
0
cmd/initialise/sql/postgres/11_settings.sql
Normal file
0
cmd/initialise/sql/postgres/11_settings.sql
Normal file
@ -38,6 +38,6 @@ func VerifyDatabase(databaseName string) func(*database.DB) error {
|
||||
return func(db *database.DB) error {
|
||||
logging.WithFields("database", databaseName).Info("verify database")
|
||||
|
||||
return exec(db, fmt.Sprintf(string(databaseStmt), databaseName), []string{dbAlreadyExistsCode})
|
||||
return exec(db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode})
|
||||
}
|
||||
}
|
||||
|
44
cmd/initialise/verify_settings.go
Normal file
44
cmd/initialise/verify_settings.go
Normal file
@ -0,0 +1,44 @@
|
||||
package initialise
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
)
|
||||
|
||||
func newSettings() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "settings",
|
||||
Short: "Ensures proper settings on the database",
|
||||
Long: `Ensures proper settings on the database.
|
||||
|
||||
Prerequisites:
|
||||
- cockroachDB or postgreSQL
|
||||
|
||||
Cockroach
|
||||
- Sets enable_durable_locking_for_serializable to on for the zitadel user and database
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := MustNewConfig(viper.GetViper())
|
||||
|
||||
err := initialise(config.Database, VerifySettings(config.Database.DatabaseName(), config.Database.Username()))
|
||||
logging.OnError(err).Fatal("unable to set settings")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func VerifySettings(databaseName, username string) func(*database.DB) error {
|
||||
return func(db *database.DB) error {
|
||||
if db.Type() == "postgres" {
|
||||
return nil
|
||||
}
|
||||
logging.WithFields("user", username, "database", databaseName).Info("verify settings")
|
||||
|
||||
return exec(db, fmt.Sprintf(settingsStmt, databaseName, username), nil)
|
||||
}
|
||||
}
|
@ -95,7 +95,8 @@ func createEncryptionKeys(ctx context.Context, db *database.DB) error {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(createEncryptionKeysStmt); err != nil {
|
||||
tx.Rollback()
|
||||
rollbackErr := tx.Rollback()
|
||||
logging.OnError(rollbackErr).Error("rollback failed")
|
||||
return err
|
||||
}
|
||||
|
||||
@ -110,7 +111,7 @@ func createEvents(ctx context.Context, db *database.DB) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
logging.OnError(rollbackErr).Debug("rollback failed")
|
||||
logging.OnError(rollbackErr).Error("rollback failed")
|
||||
return
|
||||
}
|
||||
err = tx.Commit()
|
||||
|
@ -2,7 +2,6 @@ package key
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@ -42,7 +41,7 @@ func MasterKey(cmd *cobra.Command) (string, error) {
|
||||
if masterKeyFromEnv {
|
||||
return os.Getenv(envMasterKey), nil
|
||||
}
|
||||
data, err := ioutil.ReadFile(masterKeyFile)
|
||||
data, err := os.ReadFile(masterKeyFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
91
cmd/mirror/auth.go
Normal file
91
cmd/mirror/auth.go
Normal file
@ -0,0 +1,91 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
)
|
||||
|
||||
func authCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "mirrors the auth requests table from one database to another",
|
||||
Long: `mirrors the auth requests table from one database to another
|
||||
ZITADEL needs to be initialized and set up with the --for-mirror flag
|
||||
Only auth requests are mirrored`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := mustNewMigrationConfig(viper.GetViper())
|
||||
copyAuth(cmd.Context(), config)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&shouldReplace, "replace", false, "allow delete auth requests of defined instances before copy")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func copyAuth(ctx context.Context, config *Migration) {
|
||||
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery)
|
||||
logging.OnError(err).Fatal("unable to connect to source database")
|
||||
defer sourceClient.Close()
|
||||
|
||||
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
|
||||
logging.OnError(err).Fatal("unable to connect to destination database")
|
||||
defer destClient.Close()
|
||||
|
||||
copyAuthRequests(ctx, sourceClient, destClient)
|
||||
}
|
||||
|
||||
func copyAuthRequests(ctx context.Context, source, dest *database.DB) {
|
||||
start := time.Now()
|
||||
|
||||
sourceConn, err := source.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire connection")
|
||||
defer sourceConn.Close()
|
||||
|
||||
r, w := io.Pipe()
|
||||
errs := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
err = sourceConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
_, err := conn.PgConn().CopyTo(ctx, w, "COPY (SELECT id, regexp_replace(request::TEXT, '\\\\u0000', '', 'g')::JSON request, code, request_type, creation_date, change_date, instance_id FROM auth.auth_requests "+instanceClause()+") TO STDOUT")
|
||||
w.Close()
|
||||
return err
|
||||
})
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
destConn, err := dest.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire connection")
|
||||
defer destConn.Close()
|
||||
|
||||
var affected int64
|
||||
err = destConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
|
||||
if shouldReplace {
|
||||
_, err := conn.Exec(ctx, "DELETE FROM auth.auth_requests "+instanceClause())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY auth.auth_requests FROM STDIN")
|
||||
affected = tag.RowsAffected()
|
||||
|
||||
return err
|
||||
})
|
||||
logging.OnError(err).Fatal("unable to copy auth requests to destination")
|
||||
logging.OnError(<-errs).Fatal("unable to copy auth requests from source")
|
||||
logging.WithFields("took", time.Since(start), "count", affected).Info("auth requests migrated")
|
||||
}
|
80
cmd/mirror/config.go
Normal file
80
cmd/mirror/config.go
Normal file
@ -0,0 +1,80 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/cmd/hooks"
|
||||
"github.com/zitadel/zitadel/internal/actions"
|
||||
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/config/hook"
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Source database.Config
|
||||
Destination database.Config
|
||||
|
||||
EventBulkSize uint32
|
||||
|
||||
Log *logging.Config
|
||||
Machine *id.Config
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed defaults.yaml
|
||||
defaultConfig []byte
|
||||
)
|
||||
|
||||
func mustNewMigrationConfig(v *viper.Viper) *Migration {
|
||||
config := new(Migration)
|
||||
mustNewConfig(v, config)
|
||||
|
||||
err := config.Log.SetLogger()
|
||||
logging.OnError(err).Fatal("unable to set logger")
|
||||
|
||||
id.Configure(config.Machine)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func mustNewProjectionsConfig(v *viper.Viper) *ProjectionsConfig {
|
||||
config := new(ProjectionsConfig)
|
||||
mustNewConfig(v, config)
|
||||
|
||||
err := config.Log.SetLogger()
|
||||
logging.OnError(err).Fatal("unable to set logger")
|
||||
|
||||
id.Configure(config.Machine)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func mustNewConfig(v *viper.Viper, config any) {
|
||||
err := v.Unmarshal(config,
|
||||
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
|
||||
hooks.SliceTypeStringDecode[*command.SetQuota],
|
||||
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
|
||||
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
|
||||
hooks.MapTypeStringDecode[domain.Feature, any],
|
||||
hooks.MapHTTPHeaderStringDecode,
|
||||
hook.Base64ToBytesHookFunc(),
|
||||
hook.TagToLanguageHookFunc(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToTimeHookFunc(time.RFC3339),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
database.DecodeHook,
|
||||
actions.HTTPConfigDecodeHook,
|
||||
hook.EnumHookFunc(internal_authz.MemberTypeString),
|
||||
)),
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to read default config")
|
||||
}
|
114
cmd/mirror/defaults.yaml
Normal file
114
cmd/mirror/defaults.yaml
Normal file
@ -0,0 +1,114 @@
|
||||
Source:
|
||||
cockroach:
|
||||
Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST
|
||||
Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT
|
||||
Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE
|
||||
MaxOpenConns: 6 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
|
||||
MaxIdleConns: 6 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
|
||||
EventPushConnRatio: 0.33 # ZITADEL_DATABASE_COCKROACH_EVENTPUSHCONNRATIO
|
||||
ProjectionSpoolerConnRatio: 0.33 # ZITADEL_DATABASE_COCKROACH_PROJECTIONSPOOLERCONNRATIO
|
||||
MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME
|
||||
MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME
|
||||
Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS
|
||||
User:
|
||||
Username: zitadel # ZITADEL_DATABASE_COCKROACH_USER_USERNAME
|
||||
Password: "" # ZITADEL_DATABASE_COCKROACH_USER_PASSWORD
|
||||
SSL:
|
||||
Mode: disable # ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE
|
||||
RootCert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT
|
||||
Cert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT
|
||||
Key: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY
|
||||
# Postgres is used as soon as a value is set
|
||||
# The values describe the possible fields to set values
|
||||
postgres:
|
||||
Host: # ZITADEL_DATABASE_POSTGRES_HOST
|
||||
Port: # ZITADEL_DATABASE_POSTGRES_PORT
|
||||
Database: # ZITADEL_DATABASE_POSTGRES_DATABASE
|
||||
MaxOpenConns: # ZITADEL_DATABASE_POSTGRES_MAXOPENCONNS
|
||||
MaxIdleConns: # ZITADEL_DATABASE_POSTGRES_MAXIDLECONNS
|
||||
MaxConnLifetime: # ZITADEL_DATABASE_POSTGRES_MAXCONNLIFETIME
|
||||
MaxConnIdleTime: # ZITADEL_DATABASE_POSTGRES_MAXCONNIDLETIME
|
||||
Options: # ZITADEL_DATABASE_POSTGRES_OPTIONS
|
||||
User:
|
||||
Username: # ZITADEL_DATABASE_POSTGRES_USER_USERNAME
|
||||
Password: # ZITADEL_DATABASE_POSTGRES_USER_PASSWORD
|
||||
SSL:
|
||||
Mode: # ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE
|
||||
RootCert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_ROOTCERT
|
||||
Cert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_CERT
|
||||
Key: # ZITADEL_DATABASE_POSTGRES_USER_SSL_KEY
|
||||
|
||||
Destination:
|
||||
cockroach:
|
||||
Host: localhost # ZITADEL_DATABASE_COCKROACH_HOST
|
||||
Port: 26257 # ZITADEL_DATABASE_COCKROACH_PORT
|
||||
Database: zitadel # ZITADEL_DATABASE_COCKROACH_DATABASE
|
||||
MaxOpenConns: 0 # ZITADEL_DATABASE_COCKROACH_MAXOPENCONNS
|
||||
MaxIdleConns: 0 # ZITADEL_DATABASE_COCKROACH_MAXIDLECONNS
|
||||
MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME
|
||||
MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME
|
||||
EventPushConnRatio: 0.01 # ZITADEL_DATABASE_COCKROACH_EVENTPUSHCONNRATIO
|
||||
ProjectionSpoolerConnRatio: 0.5 # ZITADEL_DATABASE_COCKROACH_PROJECTIONSPOOLERCONNRATIO
|
||||
Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS
|
||||
User:
|
||||
Username: zitadel # ZITADEL_DATABASE_COCKROACH_USER_USERNAME
|
||||
Password: "" # ZITADEL_DATABASE_COCKROACH_USER_PASSWORD
|
||||
SSL:
|
||||
Mode: disable # ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE
|
||||
RootCert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT
|
||||
Cert: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT
|
||||
Key: "" # ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY
|
||||
# Postgres is used as soon as a value is set
|
||||
# The values describe the possible fields to set values
|
||||
postgres:
|
||||
Host: # ZITADEL_DATABASE_POSTGRES_HOST
|
||||
Port: # ZITADEL_DATABASE_POSTGRES_PORT
|
||||
Database: # ZITADEL_DATABASE_POSTGRES_DATABASE
|
||||
MaxOpenConns: # ZITADEL_DATABASE_POSTGRES_MAXOPENCONNS
|
||||
MaxIdleConns: # ZITADEL_DATABASE_POSTGRES_MAXIDLECONNS
|
||||
MaxConnLifetime: # ZITADEL_DATABASE_POSTGRES_MAXCONNLIFETIME
|
||||
MaxConnIdleTime: # ZITADEL_DATABASE_POSTGRES_MAXCONNIDLETIME
|
||||
Options: # ZITADEL_DATABASE_POSTGRES_OPTIONS
|
||||
User:
|
||||
Username: # ZITADEL_DATABASE_POSTGRES_USER_USERNAME
|
||||
Password: # ZITADEL_DATABASE_POSTGRES_USER_PASSWORD
|
||||
SSL:
|
||||
Mode: # ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE
|
||||
RootCert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_ROOTCERT
|
||||
Cert: # ZITADEL_DATABASE_POSTGRES_USER_SSL_CERT
|
||||
Key: # ZITADEL_DATABASE_POSTGRES_USER_SSL_KEY
|
||||
|
||||
EventBulkSize: 10000
|
||||
|
||||
Projections:
|
||||
# The maximum duration a transaction remains open
|
||||
# before it spots left folding additional events
|
||||
# and updates the table.
|
||||
TransactionDuration: 0s # ZITADEL_PROJECTIONS_TRANSACTIONDURATION
|
||||
# turn off scheduler during operation
|
||||
RequeueEvery: 0s
|
||||
ConcurrentInstances: 7
|
||||
EventBulkLimit: 1000
|
||||
Customizations:
|
||||
notifications:
|
||||
MaxFailureCount: 1
|
||||
|
||||
Eventstore:
|
||||
MaxRetries: 3
|
||||
|
||||
Auth:
|
||||
Spooler:
|
||||
TransactionDuration: 0s #ZITADEL_AUTH_SPOOLER_TRANSACTIONDURATION
|
||||
BulkLimit: 1000 #ZITADEL_AUTH_SPOOLER_BULKLIMIT
|
||||
|
||||
Admin:
|
||||
Spooler:
|
||||
TransactionDuration: 0s #ZITADEL_AUTH_SPOOLER_TRANSACTIONDURATION
|
||||
BulkLimit: 10 #ZITADEL_AUTH_SPOOLER_BULKLIMIT
|
||||
|
||||
FirstInstance:
|
||||
# We only need to create an empty zitadel database so this step must be skipped
|
||||
Skip: true
|
||||
|
||||
Log:
|
||||
Level: info
|
96
cmd/mirror/event.go
Normal file
96
cmd/mirror/event.go
Normal file
@ -0,0 +1,96 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/v2/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/v2/projection"
|
||||
"github.com/zitadel/zitadel/internal/v2/readmodel"
|
||||
"github.com/zitadel/zitadel/internal/v2/system"
|
||||
mirror_event "github.com/zitadel/zitadel/internal/v2/system/mirror"
|
||||
)
|
||||
|
||||
func queryLastSuccessfulMigration(ctx context.Context, destinationES *eventstore.EventStore, source string) (*readmodel.LastSuccessfulMirror, error) {
|
||||
lastSuccess := readmodel.NewLastSuccessfulMirror(source)
|
||||
if shouldIgnorePrevious {
|
||||
return lastSuccess, nil
|
||||
}
|
||||
_, err := destinationES.Query(
|
||||
ctx,
|
||||
eventstore.NewQuery(
|
||||
system.AggregateInstance,
|
||||
lastSuccess,
|
||||
eventstore.SetFilters(lastSuccess.Filter()),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lastSuccess, nil
|
||||
}
|
||||
|
||||
func writeMigrationStart(ctx context.Context, sourceES *eventstore.EventStore, id string, destination string) (_ float64, err error) {
|
||||
var cmd *eventstore.Command
|
||||
if len(instanceIDs) > 0 {
|
||||
cmd, err = mirror_event.NewStartedInstancesCommand(destination, instanceIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else {
|
||||
cmd = mirror_event.NewStartedSystemCommand(destination)
|
||||
}
|
||||
|
||||
var position projection.HighestPosition
|
||||
|
||||
err = sourceES.Push(
|
||||
ctx,
|
||||
eventstore.NewPushIntent(
|
||||
system.AggregateInstance,
|
||||
eventstore.AppendAggregate(
|
||||
system.AggregateOwner,
|
||||
system.AggregateType,
|
||||
id,
|
||||
eventstore.CurrentSequenceMatches(0),
|
||||
eventstore.AppendCommands(cmd),
|
||||
),
|
||||
eventstore.PushReducer(&position),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return position.Position, nil
|
||||
}
|
||||
|
||||
func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position float64) error {
|
||||
return destinationES.Push(
|
||||
ctx,
|
||||
eventstore.NewPushIntent(
|
||||
system.AggregateInstance,
|
||||
eventstore.AppendAggregate(
|
||||
system.AggregateOwner,
|
||||
system.AggregateType,
|
||||
id,
|
||||
eventstore.CurrentSequenceMatches(0),
|
||||
eventstore.AppendCommands(mirror_event.NewSucceededCommand(source, position)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func writeMigrationFailed(ctx context.Context, destinationES *eventstore.EventStore, id, source string, err error) error {
|
||||
return destinationES.Push(
|
||||
ctx,
|
||||
eventstore.NewPushIntent(
|
||||
system.AggregateInstance,
|
||||
eventstore.AppendAggregate(
|
||||
system.AggregateOwner,
|
||||
system.AggregateType,
|
||||
id,
|
||||
eventstore.CurrentSequenceMatches(0),
|
||||
eventstore.AppendCommands(mirror_event.NewFailedCommand(source, err)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
250
cmd/mirror/event_store.go
Normal file
250
cmd/mirror/event_store.go
Normal file
@ -0,0 +1,250 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
db "github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
"github.com/zitadel/zitadel/internal/v2/database"
|
||||
"github.com/zitadel/zitadel/internal/v2/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
var shouldIgnorePrevious bool
|
||||
|
||||
func eventstoreCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "eventstore",
|
||||
Short: "mirrors the eventstore of an instance from one database to another",
|
||||
Long: `mirrors the eventstore of an instance from one database to another
|
||||
ZITADEL needs to be initialized and set up with the --for-mirror flag
|
||||
Migrate only copies events2 and unique constraints`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := mustNewMigrationConfig(viper.GetViper())
|
||||
copyEventstore(cmd.Context(), config)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&shouldReplace, "replace", false, "allow delete unique constraints of defined instances before copy")
|
||||
cmd.Flags().BoolVar(&shouldIgnorePrevious, "ignore-previous", false, "ignores previous migrations of the events table")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func copyEventstore(ctx context.Context, config *Migration) {
|
||||
sourceClient, err := db.Connect(config.Source, false, dialect.DBPurposeQuery)
|
||||
logging.OnError(err).Fatal("unable to connect to source database")
|
||||
defer sourceClient.Close()
|
||||
|
||||
destClient, err := db.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
|
||||
logging.OnError(err).Fatal("unable to connect to destination database")
|
||||
defer destClient.Close()
|
||||
|
||||
copyEvents(ctx, sourceClient, destClient, config.EventBulkSize)
|
||||
copyUniqueConstraints(ctx, sourceClient, destClient)
|
||||
}
|
||||
|
||||
func positionQuery(db *db.DB) string {
|
||||
switch db.Type() {
|
||||
case "postgres":
|
||||
return "SELECT EXTRACT(EPOCH FROM clock_timestamp())"
|
||||
case "cockroach":
|
||||
return "SELECT cluster_logical_timestamp()"
|
||||
default:
|
||||
logging.WithFields("db_type", db.Type()).Fatal("database type not recognized")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) {
|
||||
start := time.Now()
|
||||
reader, writer := io.Pipe()
|
||||
|
||||
migrationID, err := id.SonyFlakeGenerator().Next()
|
||||
logging.OnError(err).Fatal("unable to generate migration id")
|
||||
|
||||
sourceConn, err := source.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire source connection")
|
||||
|
||||
destConn, err := dest.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire dest connection")
|
||||
|
||||
sourceES := eventstore.NewEventstoreFromOne(postgres.New(source, &postgres.Config{
|
||||
MaxRetries: 3,
|
||||
}))
|
||||
destinationES := eventstore.NewEventstoreFromOne(postgres.New(dest, &postgres.Config{
|
||||
MaxRetries: 3,
|
||||
}))
|
||||
|
||||
previousMigration, err := queryLastSuccessfulMigration(ctx, destinationES, source.DatabaseName())
|
||||
logging.OnError(err).Fatal("unable to query latest successful migration")
|
||||
|
||||
maxPosition, err := writeMigrationStart(ctx, sourceES, migrationID, dest.DatabaseName())
|
||||
logging.OnError(err).Fatal("unable to write migration started event")
|
||||
|
||||
logging.WithFields("from", previousMigration.Position, "to", maxPosition).Info("start event migration")
|
||||
|
||||
nextPos := make(chan bool, 1)
|
||||
pos := make(chan float64, 1)
|
||||
errs := make(chan error, 3)
|
||||
|
||||
go func() {
|
||||
err := sourceConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
nextPos <- true
|
||||
var i uint32
|
||||
for position := range pos {
|
||||
var stmt database.Statement
|
||||
stmt.WriteString("COPY (SELECT instance_id, aggregate_type, aggregate_id, event_type, sequence, revision, created_at, regexp_replace(payload::TEXT, '\\\\u0000', '', 'g')::JSON payload, creator, owner, ")
|
||||
stmt.WriteArg(position)
|
||||
stmt.WriteString(" position, row_number() OVER (PARTITION BY instance_id ORDER BY position, in_tx_order) AS in_tx_order FROM eventstore.events2 ")
|
||||
stmt.WriteString(instanceClause())
|
||||
stmt.WriteString(" AND ")
|
||||
database.NewNumberAtMost(maxPosition).Write(&stmt, "position")
|
||||
stmt.WriteString(" AND ")
|
||||
database.NewNumberGreater(previousMigration.Position).Write(&stmt, "position")
|
||||
stmt.WriteString(" ORDER BY instance_id, position, in_tx_order")
|
||||
stmt.WriteString(" LIMIT ")
|
||||
stmt.WriteArg(bulkSize)
|
||||
stmt.WriteString(" OFFSET ")
|
||||
stmt.WriteArg(bulkSize * i)
|
||||
stmt.WriteString(") TO STDOUT")
|
||||
|
||||
// Copy does not allow args so we use we replace the args in the statement
|
||||
tag, err := conn.PgConn().CopyTo(ctx, writer, stmt.Debug())
|
||||
if err != nil {
|
||||
return zerrors.ThrowUnknownf(err, "MIGRA-KTuSq", "unable to copy events from source during iteration %d", i)
|
||||
}
|
||||
if tag.RowsAffected() < int64(bulkSize) {
|
||||
return nil
|
||||
}
|
||||
|
||||
nextPos <- true
|
||||
i++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
writer.Close()
|
||||
close(nextPos)
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
// generate next position for
|
||||
go func() {
|
||||
defer close(pos)
|
||||
for range nextPos {
|
||||
var position float64
|
||||
err := dest.QueryRowContext(
|
||||
ctx,
|
||||
func(row *sql.Row) error {
|
||||
return row.Scan(&position)
|
||||
},
|
||||
positionQuery(dest),
|
||||
)
|
||||
if err != nil {
|
||||
errs <- zerrors.ThrowUnknown(err, "MIGRA-kMyPH", "unable to query next position")
|
||||
return
|
||||
}
|
||||
pos <- position
|
||||
}
|
||||
}()
|
||||
|
||||
var eventCount int64
|
||||
errs <- destConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
|
||||
tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.events2 FROM STDIN")
|
||||
eventCount = tag.RowsAffected()
|
||||
if err != nil {
|
||||
return zerrors.ThrowUnknown(err, "MIGRA-DTHi7", "unable to copy events into destination")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
close(errs)
|
||||
writeCopyEventsDone(ctx, destinationES, migrationID, source.DatabaseName(), maxPosition, errs)
|
||||
|
||||
logging.WithFields("took", time.Since(start), "count", eventCount).Info("events migrated")
|
||||
}
|
||||
|
||||
func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, source string, position float64, errs <-chan error) {
|
||||
joinedErrs := make([]error, 0, len(errs))
|
||||
for err := range errs {
|
||||
joinedErrs = append(joinedErrs, err)
|
||||
}
|
||||
err := errors.Join(joinedErrs...)
|
||||
|
||||
if err != nil {
|
||||
logging.WithError(err).Error("unable to mirror events")
|
||||
err := writeMigrationFailed(ctx, es, id, source, err)
|
||||
logging.OnError(err).Fatal("unable to write failed event")
|
||||
return
|
||||
}
|
||||
|
||||
err = writeMigrationSucceeded(ctx, es, id, source, position)
|
||||
logging.OnError(err).Fatal("unable to write failed event")
|
||||
}
|
||||
|
||||
func copyUniqueConstraints(ctx context.Context, source, dest *db.DB) {
|
||||
start := time.Now()
|
||||
reader, writer := io.Pipe()
|
||||
errs := make(chan error, 1)
|
||||
|
||||
sourceConn, err := source.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire source connection")
|
||||
|
||||
go func() {
|
||||
err := sourceConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
var stmt database.Statement
|
||||
stmt.WriteString("COPY (SELECT instance_id, unique_type, unique_field FROM eventstore.unique_constraints ")
|
||||
stmt.WriteString(instanceClause())
|
||||
stmt.WriteString(") TO stdout")
|
||||
|
||||
_, err := conn.PgConn().CopyTo(ctx, writer, stmt.String())
|
||||
writer.Close()
|
||||
return err
|
||||
})
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
destConn, err := dest.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire dest connection")
|
||||
|
||||
var eventCount int64
|
||||
err = destConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
|
||||
if shouldReplace {
|
||||
var stmt database.Statement
|
||||
stmt.WriteString("DELETE FROM eventstore.unique_constraints ")
|
||||
stmt.WriteString(instanceClause())
|
||||
|
||||
_, err := conn.Exec(ctx, stmt.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.unique_constraints FROM stdin")
|
||||
eventCount = tag.RowsAffected()
|
||||
|
||||
return err
|
||||
})
|
||||
logging.OnError(err).Fatal("unable to copy unique constraints to destination")
|
||||
logging.OnError(<-errs).Fatal("unable to copy unique constraints from source")
|
||||
logging.WithFields("took", time.Since(start), "count", eventCount).Info("unique constraints migrated")
|
||||
}
|
100
cmd/mirror/mirror.go
Normal file
100
cmd/mirror/mirror.go
Normal file
@ -0,0 +1,100 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/cmd/key"
|
||||
)
|
||||
|
||||
var (
|
||||
instanceIDs []string
|
||||
isSystem bool
|
||||
shouldReplace bool
|
||||
)
|
||||
|
||||
func New(configFiles *[]string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mirror",
|
||||
Short: "mirrors all data of ZITADEL from one database to another",
|
||||
Long: `mirrors all data of ZITADEL from one database to another
|
||||
ZITADEL needs to be initialized and set up with --for-mirror
|
||||
|
||||
The command does mirror all data needed and recomputes the projections.
|
||||
For more details call the help functions of the sub commands.
|
||||
|
||||
Order of execution:
|
||||
1. mirror system tables
|
||||
2. mirror auth tables
|
||||
3. mirror event store tables
|
||||
4. recompute projections
|
||||
5. verify`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
err := viper.MergeConfig(bytes.NewBuffer(defaultConfig))
|
||||
logging.OnError(err).Fatal("unable to read default config")
|
||||
|
||||
for _, file := range *configFiles {
|
||||
viper.SetConfigFile(file)
|
||||
err := viper.MergeInConfig()
|
||||
logging.WithFields("file", file).OnError(err).Warn("unable to read config file")
|
||||
}
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := mustNewMigrationConfig(viper.GetViper())
|
||||
projectionConfig := mustNewProjectionsConfig(viper.GetViper())
|
||||
|
||||
masterKey, err := key.MasterKey(cmd)
|
||||
logging.OnError(err).Fatal("unable to read master key")
|
||||
|
||||
copySystem(cmd.Context(), config)
|
||||
copyAuth(cmd.Context(), config)
|
||||
copyEventstore(cmd.Context(), config)
|
||||
|
||||
projections(cmd.Context(), projectionConfig, masterKey)
|
||||
verifyMigration(cmd.Context(), config)
|
||||
},
|
||||
}
|
||||
|
||||
mirrorFlags(cmd)
|
||||
cmd.Flags().BoolVar(&shouldIgnorePrevious, "ignore-previous", false, "ignores previous migrations of the events table")
|
||||
cmd.Flags().BoolVar(&shouldReplace, "replace", false, `replaces all data of the following tables for the provided instances or all if the "--system"-flag is set:
|
||||
* system.assets
|
||||
* auth.auth_requests
|
||||
* eventstore.unique_constraints
|
||||
The flag should be provided if you want to execute the mirror command multiple times so that the static data are also mirrored to prevent inconsistent states.`)
|
||||
migrateProjectionsFlags(cmd)
|
||||
|
||||
cmd.AddCommand(
|
||||
eventstoreCmd(),
|
||||
systemCmd(),
|
||||
projectionsCmd(),
|
||||
authCmd(),
|
||||
verifyCmd(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mirrorFlags(cmd *cobra.Command) {
|
||||
cmd.PersistentFlags().StringSliceVar(&instanceIDs, "instance", nil, "id or comma separated ids of the instance(s) to migrate. Either this or the `--system`-flag must be set. Make sure to always use the same flag if you execute the command multiple times.")
|
||||
cmd.PersistentFlags().BoolVar(&isSystem, "system", false, "migrates the whole system. Either this or the `--instance`-flag must be set. Make sure to always use the same flag if you execute the command multiple times.")
|
||||
cmd.MarkFlagsOneRequired("system", "instance")
|
||||
cmd.MarkFlagsMutuallyExclusive("system", "instance")
|
||||
}
|
||||
|
||||
func instanceClause() string {
|
||||
if isSystem {
|
||||
return "WHERE instance_id <> ''"
|
||||
}
|
||||
for i := range instanceIDs {
|
||||
instanceIDs[i] = "'" + instanceIDs[i] + "'"
|
||||
}
|
||||
|
||||
// COPY does not allow parameters so we need to set them directly
|
||||
return "WHERE instance_id IN (" + strings.Join(instanceIDs, ", ") + ")"
|
||||
}
|
316
cmd/mirror/projections.go
Normal file
316
cmd/mirror/projections.go
Normal file
@ -0,0 +1,316 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/cmd/encryption"
|
||||
"github.com/zitadel/zitadel/cmd/key"
|
||||
"github.com/zitadel/zitadel/cmd/tls"
|
||||
admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
|
||||
admin_handler "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/handler"
|
||||
admin_view "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/view"
|
||||
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
|
||||
auth_handler "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
|
||||
auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view"
|
||||
"github.com/zitadel/zitadel/internal/authz"
|
||||
authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
crypto_db "github.com/zitadel/zitadel/internal/crypto/database"
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
old_es "github.com/zitadel/zitadel/internal/eventstore/repository/sql"
|
||||
new_es "github.com/zitadel/zitadel/internal/eventstore/v3"
|
||||
"github.com/zitadel/zitadel/internal/i18n"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
"github.com/zitadel/zitadel/internal/notification"
|
||||
"github.com/zitadel/zitadel/internal/notification/handlers"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
static_config "github.com/zitadel/zitadel/internal/static/config"
|
||||
es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore"
|
||||
es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
|
||||
"github.com/zitadel/zitadel/internal/webauthn"
|
||||
)
|
||||
|
||||
func projectionsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "projections",
|
||||
Short: "calls the projections synchronously",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := mustNewProjectionsConfig(viper.GetViper())
|
||||
|
||||
masterKey, err := key.MasterKey(cmd)
|
||||
logging.OnError(err).Fatal("unable to read master key")
|
||||
|
||||
projections(cmd.Context(), config, masterKey)
|
||||
},
|
||||
}
|
||||
|
||||
migrateProjectionsFlags(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type ProjectionsConfig struct {
|
||||
Destination database.Config
|
||||
Projections projection.Config
|
||||
EncryptionKeys *encryption.EncryptionKeyConfig
|
||||
SystemAPIUsers map[string]*internal_authz.SystemAPIUser
|
||||
Eventstore *eventstore.Config
|
||||
|
||||
Admin admin_es.Config
|
||||
Auth auth_es.Config
|
||||
|
||||
Log *logging.Config
|
||||
Machine *id.Config
|
||||
|
||||
ExternalPort uint16
|
||||
ExternalDomain string
|
||||
ExternalSecure bool
|
||||
InternalAuthZ internal_authz.Config
|
||||
SystemDefaults systemdefaults.SystemDefaults
|
||||
Telemetry *handlers.TelemetryPusherConfig
|
||||
Login login.Config
|
||||
OIDC oidc.Config
|
||||
WebAuthNName string
|
||||
DefaultInstance command.InstanceSetup
|
||||
AssetStorage static_config.AssetStorageConfig
|
||||
}
|
||||
|
||||
func migrateProjectionsFlags(cmd *cobra.Command) {
|
||||
key.AddMasterKeyFlag(cmd)
|
||||
tls.AddTLSModeFlag(cmd)
|
||||
}
|
||||
|
||||
func projections(
|
||||
ctx context.Context,
|
||||
config *ProjectionsConfig,
|
||||
masterKey string,
|
||||
) {
|
||||
start := time.Now()
|
||||
|
||||
client, err := database.Connect(config.Destination, false, dialect.DBPurposeQuery)
|
||||
logging.OnError(err).Fatal("unable to connect to database")
|
||||
|
||||
keyStorage, err := crypto_db.NewKeyStorage(client, masterKey)
|
||||
logging.OnError(err).Fatal("cannot start key storage")
|
||||
|
||||
keys, err := encryption.EnsureEncryptionKeys(ctx, config.EncryptionKeys, keyStorage)
|
||||
logging.OnError(err).Fatal("unable to read encryption keys")
|
||||
|
||||
staticStorage, err := config.AssetStorage.NewStorage(client.DB)
|
||||
logging.OnError(err).Fatal("unable create static storage")
|
||||
|
||||
config.Eventstore.Querier = old_es.NewCRDB(client)
|
||||
esPusherDBClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
|
||||
logging.OnError(err).Fatal("unable to connect eventstore push client")
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
|
||||
es := eventstore.NewEventstore(config.Eventstore)
|
||||
esV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(client, &es_v4_pg.Config{
|
||||
MaxRetries: config.Eventstore.MaxRetries,
|
||||
}))
|
||||
|
||||
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
|
||||
|
||||
queries, err := query.StartQueries(
|
||||
ctx,
|
||||
es,
|
||||
esV4.Querier,
|
||||
client,
|
||||
client,
|
||||
config.Projections,
|
||||
config.SystemDefaults,
|
||||
keys.IDPConfig,
|
||||
keys.OTP,
|
||||
keys.OIDC,
|
||||
keys.SAML,
|
||||
config.InternalAuthZ.RolePermissionMappings,
|
||||
sessionTokenVerifier,
|
||||
func(q *query.Queries) domain.PermissionCheck {
|
||||
return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
|
||||
return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
|
||||
}
|
||||
},
|
||||
0,
|
||||
config.SystemAPIUsers,
|
||||
false,
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to start queries")
|
||||
|
||||
authZRepo, err := authz.Start(queries, es, client, keys.OIDC, config.ExternalSecure)
|
||||
logging.OnError(err).Fatal("unable to start authz repo")
|
||||
|
||||
webAuthNConfig := &webauthn.Config{
|
||||
DisplayName: config.WebAuthNName,
|
||||
ExternalSecure: config.ExternalSecure,
|
||||
}
|
||||
commands, err := command.StartCommands(
|
||||
es,
|
||||
config.SystemDefaults,
|
||||
config.InternalAuthZ.RolePermissionMappings,
|
||||
staticStorage,
|
||||
webAuthNConfig,
|
||||
config.ExternalDomain,
|
||||
config.ExternalSecure,
|
||||
config.ExternalPort,
|
||||
keys.IDPConfig,
|
||||
keys.OTP,
|
||||
keys.SMTP,
|
||||
keys.SMS,
|
||||
keys.User,
|
||||
keys.DomainVerification,
|
||||
keys.OIDC,
|
||||
keys.SAML,
|
||||
&http.Client{},
|
||||
func(ctx context.Context, permission, orgID, resourceID string) (err error) {
|
||||
return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
|
||||
},
|
||||
sessionTokenVerifier,
|
||||
config.OIDC.DefaultAccessTokenLifetime,
|
||||
config.OIDC.DefaultRefreshTokenExpiration,
|
||||
config.OIDC.DefaultRefreshTokenIdleExpiration,
|
||||
config.DefaultInstance.SecretGenerators,
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to start commands")
|
||||
|
||||
err = projection.Create(ctx, client, es, config.Projections, keys.OIDC, keys.SAML, config.SystemAPIUsers)
|
||||
logging.OnError(err).Fatal("unable to start projections")
|
||||
|
||||
i18n.MustLoadSupportedLanguagesFromDir()
|
||||
|
||||
notification.Register(
|
||||
ctx,
|
||||
config.Projections.Customizations["notifications"],
|
||||
config.Projections.Customizations["notificationsquotas"],
|
||||
config.Projections.Customizations["telemetry"],
|
||||
*config.Telemetry,
|
||||
config.ExternalDomain,
|
||||
config.ExternalPort,
|
||||
config.ExternalSecure,
|
||||
commands,
|
||||
queries,
|
||||
es,
|
||||
config.Login.DefaultOTPEmailURLV2,
|
||||
config.SystemDefaults.Notifications.FileSystemPath,
|
||||
keys.User,
|
||||
keys.SMTP,
|
||||
keys.SMS,
|
||||
)
|
||||
|
||||
config.Auth.Spooler.Client = client
|
||||
config.Auth.Spooler.Eventstore = es
|
||||
authView, err := auth_view.StartView(config.Auth.Spooler.Client, keys.OIDC, queries, config.Auth.Spooler.Eventstore)
|
||||
logging.OnError(err).Fatal("unable to start auth view")
|
||||
auth_handler.Register(ctx, config.Auth.Spooler, authView, queries)
|
||||
|
||||
config.Admin.Spooler.Client = client
|
||||
config.Admin.Spooler.Eventstore = es
|
||||
adminView, err := admin_view.StartView(config.Admin.Spooler.Client)
|
||||
logging.OnError(err).Fatal("unable to start admin view")
|
||||
|
||||
admin_handler.Register(ctx, config.Admin.Spooler, adminView, staticStorage)
|
||||
|
||||
instances := make(chan string, config.Projections.ConcurrentInstances)
|
||||
failedInstances := make(chan string)
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(int(config.Projections.ConcurrentInstances))
|
||||
|
||||
go func() {
|
||||
for instance := range failedInstances {
|
||||
logging.WithFields("instance", instance).Error("projection failed")
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < int(config.Projections.ConcurrentInstances); i++ {
|
||||
go execProjections(ctx, instances, failedInstances, &wg)
|
||||
}
|
||||
|
||||
for _, instance := range queryInstanceIDs(ctx, client) {
|
||||
instances <- instance
|
||||
}
|
||||
close(instances)
|
||||
wg.Wait()
|
||||
|
||||
close(failedInstances)
|
||||
|
||||
logging.WithFields("took", time.Since(start)).Info("projections executed")
|
||||
}
|
||||
|
||||
func execProjections(ctx context.Context, instances <-chan string, failedInstances chan<- string, wg *sync.WaitGroup) {
|
||||
for instance := range instances {
|
||||
logging.WithFields("instance", instance).Info("start projections")
|
||||
ctx = internal_authz.WithInstanceID(ctx, instance)
|
||||
|
||||
err := projection.ProjectInstance(ctx)
|
||||
if err != nil {
|
||||
logging.WithFields("instance", instance).OnError(err).Info("trigger failed")
|
||||
failedInstances <- instance
|
||||
continue
|
||||
}
|
||||
|
||||
err = admin_handler.ProjectInstance(ctx)
|
||||
if err != nil {
|
||||
logging.WithFields("instance", instance).OnError(err).Info("trigger admin handler failed")
|
||||
failedInstances <- instance
|
||||
continue
|
||||
}
|
||||
|
||||
err = auth_handler.ProjectInstance(ctx)
|
||||
if err != nil {
|
||||
logging.WithFields("instance", instance).OnError(err).Info("trigger auth handler failed")
|
||||
failedInstances <- instance
|
||||
continue
|
||||
}
|
||||
|
||||
err = notification.ProjectInstance(ctx)
|
||||
if err != nil {
|
||||
logging.WithFields("instance", instance).OnError(err).Info("trigger notification failed")
|
||||
failedInstances <- instance
|
||||
continue
|
||||
}
|
||||
logging.WithFields("instance", instance).Info("projections done")
|
||||
}
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
// returns the instance configured by flag
|
||||
// or all instances which are not removed
|
||||
func queryInstanceIDs(ctx context.Context, source *database.DB) []string {
|
||||
if len(instanceIDs) > 0 {
|
||||
return instanceIDs
|
||||
}
|
||||
|
||||
instances := []string{}
|
||||
err := source.QueryContext(
|
||||
ctx,
|
||||
func(r *sql.Rows) error {
|
||||
for r.Next() {
|
||||
var instance string
|
||||
|
||||
if err := r.Scan(&instance); err != nil {
|
||||
return err
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
return r.Err()
|
||||
},
|
||||
"SELECT DISTINCT instance_id FROM eventstore.events2 WHERE instance_id <> '' AND aggregate_type = 'instance' AND event_type = 'instance.added' AND instance_id NOT IN (SELECT instance_id FROM eventstore.events2 WHERE instance_id <> '' AND aggregate_type = 'instance' AND event_type = 'instance.removed')",
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to query instances")
|
||||
|
||||
return instances
|
||||
}
|
139
cmd/mirror/system.go
Normal file
139
cmd/mirror/system.go
Normal file
@ -0,0 +1,139 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
)
|
||||
|
||||
func systemCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "system",
|
||||
Short: "mirrors the system tables of ZITADEL from one database to another",
|
||||
Long: `mirrors the system tables of ZITADEL from one database to another
|
||||
ZITADEL needs to be initialized
|
||||
Only keys and assets are mirrored`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := mustNewMigrationConfig(viper.GetViper())
|
||||
copySystem(cmd.Context(), config)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&shouldReplace, "replace", false, "allow delete ALL keys and assets of defined instances before copy")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func copySystem(ctx context.Context, config *Migration) {
|
||||
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery)
|
||||
logging.OnError(err).Fatal("unable to connect to source database")
|
||||
defer sourceClient.Close()
|
||||
|
||||
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
|
||||
logging.OnError(err).Fatal("unable to connect to destination database")
|
||||
defer destClient.Close()
|
||||
|
||||
copyAssets(ctx, sourceClient, destClient)
|
||||
copyEncryptionKeys(ctx, sourceClient, destClient)
|
||||
}
|
||||
|
||||
func copyAssets(ctx context.Context, source, dest *database.DB) {
|
||||
start := time.Now()
|
||||
|
||||
sourceConn, err := source.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire source connection")
|
||||
defer sourceConn.Close()
|
||||
|
||||
r, w := io.Pipe()
|
||||
errs := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
err = sourceConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
// ignore hash column because it's computed
|
||||
_, err := conn.PgConn().CopyTo(ctx, w, "COPY (SELECT instance_id, asset_type, resource_owner, name, content_type, data, updated_at FROM system.assets "+instanceClause()+") TO stdout")
|
||||
w.Close()
|
||||
return err
|
||||
})
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
destConn, err := dest.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire dest connection")
|
||||
defer destConn.Close()
|
||||
|
||||
var eventCount int64
|
||||
err = destConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
|
||||
if shouldReplace {
|
||||
_, err := conn.Exec(ctx, "DELETE FROM system.assets "+instanceClause())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY system.assets (instance_id, asset_type, resource_owner, name, content_type, data, updated_at) FROM stdin")
|
||||
eventCount = tag.RowsAffected()
|
||||
|
||||
return err
|
||||
})
|
||||
logging.OnError(err).Fatal("unable to copy assets to destination")
|
||||
logging.OnError(<-errs).Fatal("unable to copy assets from source")
|
||||
logging.WithFields("took", time.Since(start), "count", eventCount).Info("assets migrated")
|
||||
}
|
||||
|
||||
func copyEncryptionKeys(ctx context.Context, source, dest *database.DB) {
|
||||
start := time.Now()
|
||||
|
||||
sourceConn, err := source.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire source connection")
|
||||
defer sourceConn.Close()
|
||||
|
||||
r, w := io.Pipe()
|
||||
errs := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
err = sourceConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
// ignore hash column because it's computed
|
||||
_, err := conn.PgConn().CopyTo(ctx, w, "COPY system.encryption_keys TO stdout")
|
||||
w.Close()
|
||||
return err
|
||||
})
|
||||
errs <- err
|
||||
}()
|
||||
|
||||
destConn, err := dest.Conn(ctx)
|
||||
logging.OnError(err).Fatal("unable to acquire dest connection")
|
||||
defer destConn.Close()
|
||||
|
||||
var eventCount int64
|
||||
err = destConn.Raw(func(driverConn interface{}) error {
|
||||
conn := driverConn.(*stdlib.Conn).Conn()
|
||||
|
||||
if shouldReplace {
|
||||
_, err := conn.Exec(ctx, "TRUNCATE system.encryption_keys")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY system.encryption_keys FROM stdin")
|
||||
eventCount = tag.RowsAffected()
|
||||
|
||||
return err
|
||||
})
|
||||
logging.OnError(err).Fatal("unable to copy encryption keys to destination")
|
||||
logging.OnError(<-errs).Fatal("unable to copy encryption keys from source")
|
||||
logging.WithFields("took", time.Since(start), "count", eventCount).Info("encryption keys migrated")
|
||||
}
|
111
cmd/mirror/verify.go
Normal file
111
cmd/mirror/verify.go
Normal file
@ -0,0 +1,111 @@
|
||||
package mirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/database/dialect"
|
||||
)
|
||||
|
||||
func verifyCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "counts if source and dest have the same amount of entries",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
config := mustNewMigrationConfig(viper.GetViper())
|
||||
verifyMigration(cmd.Context(), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var schemas = []string{
|
||||
"adminapi",
|
||||
"auth",
|
||||
"eventstore",
|
||||
"projections",
|
||||
"system",
|
||||
}
|
||||
|
||||
func verifyMigration(ctx context.Context, config *Migration) {
|
||||
sourceClient, err := database.Connect(config.Source, false, dialect.DBPurposeQuery)
|
||||
logging.OnError(err).Fatal("unable to connect to source database")
|
||||
defer sourceClient.Close()
|
||||
|
||||
destClient, err := database.Connect(config.Destination, false, dialect.DBPurposeEventPusher)
|
||||
logging.OnError(err).Fatal("unable to connect to destination database")
|
||||
defer destClient.Close()
|
||||
|
||||
for _, schema := range schemas {
|
||||
for _, table := range append(getTables(ctx, destClient, schema), getViews(ctx, destClient, schema)...) {
|
||||
sourceCount := countEntries(ctx, sourceClient, table)
|
||||
destCount := countEntries(ctx, destClient, table)
|
||||
|
||||
entry := logging.WithFields("table", table, "dest", destCount, "source", sourceCount)
|
||||
if sourceCount == destCount {
|
||||
entry.Debug("equal count")
|
||||
continue
|
||||
}
|
||||
entry.WithField("diff", destCount-sourceCount).Info("unequal count")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getTables(ctx context.Context, dest *database.DB, schema string) (tables []string) {
|
||||
err := dest.QueryContext(
|
||||
ctx,
|
||||
func(r *sql.Rows) error {
|
||||
for r.Next() {
|
||||
var table string
|
||||
if err := r.Scan(&table); err != nil {
|
||||
return err
|
||||
}
|
||||
tables = append(tables, table)
|
||||
}
|
||||
return r.Err()
|
||||
},
|
||||
"SELECT CONCAT(schemaname, '.', tablename) FROM pg_tables WHERE schemaname = $1",
|
||||
schema,
|
||||
)
|
||||
logging.WithFields("schema", schema).OnError(err).Fatal("unable to query tables")
|
||||
return tables
|
||||
}
|
||||
|
||||
func getViews(ctx context.Context, dest *database.DB, schema string) (tables []string) {
|
||||
err := dest.QueryContext(
|
||||
ctx,
|
||||
func(r *sql.Rows) error {
|
||||
for r.Next() {
|
||||
var table string
|
||||
if err := r.Scan(&table); err != nil {
|
||||
return err
|
||||
}
|
||||
tables = append(tables, table)
|
||||
}
|
||||
return r.Err()
|
||||
},
|
||||
"SELECT CONCAT(schemaname, '.', viewname) FROM pg_views WHERE schemaname = $1",
|
||||
schema,
|
||||
)
|
||||
logging.WithFields("schema", schema).OnError(err).Fatal("unable to query views")
|
||||
return tables
|
||||
}
|
||||
|
||||
func countEntries(ctx context.Context, client *database.DB, table string) (count int) {
|
||||
err := client.QueryRowContext(
|
||||
ctx,
|
||||
func(r *sql.Row) error {
|
||||
return r.Scan(&count)
|
||||
},
|
||||
fmt.Sprintf("SELECT COUNT(*) FROM %s %s", table, instanceClause()),
|
||||
)
|
||||
logging.WithFields("table", table, "db", client.DatabaseName()).OnError(err).Error("unable to count")
|
||||
|
||||
return count
|
||||
}
|
@ -26,6 +26,8 @@ type FirstInstance struct {
|
||||
PatPath string
|
||||
Features *command.InstanceFeatures
|
||||
|
||||
Skip bool
|
||||
|
||||
instanceSetup command.InstanceSetup
|
||||
userEncryptionKey *crypto.KeyConfig
|
||||
smtpEncryptionKey *crypto.KeyConfig
|
||||
@ -42,6 +44,9 @@ type FirstInstance struct {
|
||||
}
|
||||
|
||||
func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
if mig.Skip {
|
||||
return nil
|
||||
}
|
||||
keyStorage, err := mig.verifyEncryptionKeys(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
|
27
cmd/setup/24.go
Normal file
27
cmd/setup/24.go
Normal file
@ -0,0 +1,27 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed 24.sql
|
||||
addTokenActor string
|
||||
)
|
||||
|
||||
type AddActorToAuthTokens struct {
|
||||
dbClient *database.DB
|
||||
}
|
||||
|
||||
func (mig *AddActorToAuthTokens) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
_, err := mig.dbClient.ExecContext(ctx, addTokenActor)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mig *AddActorToAuthTokens) String() string {
|
||||
return "24_add_actor_col_to_auth_tokens"
|
||||
}
|
2
cmd/setup/24.sql
Normal file
2
cmd/setup/24.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE auth.tokens ADD COLUMN actor jsonb;
|
||||
ALTER TABLE auth.refresh_tokens ADD COLUMN actor jsonb;
|
27
cmd/setup/25.go
Normal file
27
cmd/setup/25.go
Normal file
@ -0,0 +1,27 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed 25.sql
|
||||
addLowerFieldsToVerifiedEmail string
|
||||
)
|
||||
|
||||
type User11AddLowerFieldsToVerifiedEmail struct {
|
||||
dbClient *database.DB
|
||||
}
|
||||
|
||||
func (mig *User11AddLowerFieldsToVerifiedEmail) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
_, err := mig.dbClient.ExecContext(ctx, addLowerFieldsToVerifiedEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mig *User11AddLowerFieldsToVerifiedEmail) String() string {
|
||||
return "25_user13_add_lower_fields_to_verified_email"
|
||||
}
|
2
cmd/setup/25.sql
Normal file
2
cmd/setup/25.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE IF EXISTS projections.users13_notifications ADD COLUMN IF NOT EXISTS verified_email_lower TEXT GENERATED ALWAYS AS (lower(verified_email)) STORED;
|
||||
CREATE INDEX IF NOT EXISTS users13_notifications_email_search ON projections.users13_notifications (instance_id, verified_email_lower);
|
27
cmd/setup/26.go
Normal file
27
cmd/setup/26.go
Normal file
@ -0,0 +1,27 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed 26.sql
|
||||
authUsers3 string
|
||||
)
|
||||
|
||||
type AuthUsers3 struct {
|
||||
dbClient *database.DB
|
||||
}
|
||||
|
||||
func (mig *AuthUsers3) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
_, err := mig.dbClient.ExecContext(ctx, authUsers3)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mig *AuthUsers3) String() string {
|
||||
return "26_auth_users3"
|
||||
}
|
16
cmd/setup/26.sql
Normal file
16
cmd/setup/26.sql
Normal file
@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS auth.users3 (
|
||||
instance_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
resource_owner TEXT NOT NULL,
|
||||
change_date TIMESTAMPTZ NULL,
|
||||
password_set BOOL NULL,
|
||||
password_change TIMESTAMPTZ NULL,
|
||||
last_login TIMESTAMPTZ NULL,
|
||||
init_required BOOL NULL,
|
||||
mfa_init_skipped TIMESTAMPTZ NULL,
|
||||
username_change_required BOOL NULL,
|
||||
passwordless_init_required BOOL NULL,
|
||||
password_init_required BOOL NULL,
|
||||
|
||||
PRIMARY KEY (instance_id, id)
|
||||
)
|
27
cmd/setup/27.go
Normal file
27
cmd/setup/27.go
Normal file
@ -0,0 +1,27 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed 27.sql
|
||||
addSAMLNameIDFormat string
|
||||
)
|
||||
|
||||
type IDPTemplate6SAMLNameIDFormat struct {
|
||||
dbClient *database.DB
|
||||
}
|
||||
|
||||
func (mig *IDPTemplate6SAMLNameIDFormat) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
_, err := mig.dbClient.ExecContext(ctx, addSAMLNameIDFormat)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mig *IDPTemplate6SAMLNameIDFormat) String() string {
|
||||
return "26_idp_templates6_add_saml_name_id_format"
|
||||
}
|
2
cmd/setup/27.sql
Normal file
2
cmd/setup/27.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE IF EXISTS projections.idp_templates6_saml ADD COLUMN IF NOT EXISTS name_id_format SMALLINT;
|
||||
ALTER TABLE IF EXISTS projections.idp_templates6_saml ADD COLUMN IF NOT EXISTS transient_mapping_attribute_name TEXT;
|
27
cmd/setup/28.go
Normal file
27
cmd/setup/28.go
Normal file
@ -0,0 +1,27 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed 28.sql
|
||||
addFieldTable string
|
||||
)
|
||||
|
||||
type AddFieldTable struct {
|
||||
dbClient *database.DB
|
||||
}
|
||||
|
||||
func (mig *AddFieldTable) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
_, err := mig.dbClient.ExecContext(ctx, addFieldTable)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mig *AddFieldTable) String() string {
|
||||
return "28_add_search_table"
|
||||
}
|
64
cmd/setup/28.sql
Normal file
64
cmd/setup/28.sql
Normal file
@ -0,0 +1,64 @@
|
||||
CREATE TABLE eventstore.fields (
|
||||
id TEXT NOT NULL DEFAULT gen_random_uuid()
|
||||
, instance_id TEXT NOT NULL
|
||||
, resource_owner TEXT NOT NULL
|
||||
|
||||
, aggregate_type TEXT NOT NULL
|
||||
, aggregate_id TEXT NOT NULL
|
||||
|
||||
, object_type TEXT NOT NULL
|
||||
, object_id TEXT NOT NULL
|
||||
, object_revision INT2
|
||||
|
||||
, field_name TEXT NOT NULL
|
||||
|
||||
-- all the values of fields are inserted into value column as jsonb and if we need to index something we store it to the type specific column additionally
|
||||
, "value" JSONB NOT NULL
|
||||
, number_value NUMERIC GENERATED ALWAYS AS (CASE WHEN should_index AND JSONB_TYPEOF("value") = 'number' THEN "value"::NUMERIC ELSE NULL END) STORED
|
||||
, text_value TEXT GENERATED ALWAYS AS (CASE WHEN should_index AND JSONB_TYPEOF("value") = 'string' THEN "value" #>> '{}' ELSE NULL END) STORED
|
||||
, bool_value BOOLEAN GENERATED ALWAYS AS (CASE WHEN should_index AND JSONB_TYPEOF("value") = 'boolean' THEN "value"::BOOLEAN ELSE NULL END) STORED
|
||||
|
||||
-- if true the value must be unique within an instance
|
||||
, value_must_be_unique BOOLEAN
|
||||
-- if set to true the primitive value is indexed
|
||||
, should_index BOOLEAN
|
||||
|
||||
, PRIMARY KEY (instance_id, id)
|
||||
-- TODO: create issue to enable the foreign key as soon as the objects table is implemented
|
||||
-- , CONSTRAINT f_objects_fk FOREIGN KEY (instance_id, resource_owner, object_type, object_id, object_revision) REFERENCES eventstore.objects (instance_id, resource_owner, object_type, object_id, object_revision) ON DELETE CASCADE
|
||||
|
||||
-- the constraint ensures that a primitive value is set if the value must be unique
|
||||
, CONSTRAINT primitive_value_for_unique_check CHECK (
|
||||
CASE
|
||||
WHEN value_must_be_unique THEN num_nonnulls(number_value, text_value, bool_value) = 1
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
-- the constraint ensures that a primitive value is set if the value must be indexed
|
||||
, CONSTRAINT primitive_value_for_index CHECK (
|
||||
CASE
|
||||
WHEN should_index THEN num_nonnulls(number_value, text_value, bool_value) = 1
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
);
|
||||
|
||||
-- unique constraints for primitive values
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS f_number_unique_idx ON eventstore.fields (instance_id, field_name, number_value) WHERE value_must_be_unique;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS f_text_unique_idx ON eventstore.fields (instance_id, field_name, text_value) WHERE value_must_be_unique;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS f_bool_unique_idx ON eventstore.fields (instance_id, field_name, bool_value) WHERE value_must_be_unique;
|
||||
|
||||
-- search index for primitive values
|
||||
CREATE INDEX IF NOT EXISTS f_number_value_idx ON eventstore.fields (instance_id, object_type, field_name, number_value)
|
||||
INCLUDE (resource_owner, object_id, object_revision, "value")
|
||||
WHERE number_value IS NOT NULL ;
|
||||
CREATE INDEX IF NOT EXISTS f_text_value_idx ON eventstore.fields (instance_id, object_type, field_name, text_value)
|
||||
INCLUDE (resource_owner, object_id, object_revision, "value")
|
||||
WHERE text_value IS NOT NULL ;
|
||||
CREATE INDEX IF NOT EXISTS f_bool_value_idx ON eventstore.fields (instance_id, object_type, field_name, bool_value)
|
||||
INCLUDE (resource_owner, object_id, object_revision, "value")
|
||||
WHERE bool_value IS NOT NULL ;
|
||||
|
||||
-- search index for object by id
|
||||
CREATE INDEX IF NOT EXISTS f_object_idx ON eventstore.fields (instance_id, object_type, object_id, object_revision)
|
||||
INCLUDE (resource_owner, field_name, "value") ;
|
42
cmd/setup/29.go
Normal file
42
cmd/setup/29.go
Normal file
@ -0,0 +1,42 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
"github.com/zitadel/zitadel/internal/repository/instance"
|
||||
)
|
||||
|
||||
type FillFieldsForProjectGrant struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
|
||||
func (mig *FillFieldsForProjectGrant) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
instances, err := mig.eventstore.InstanceIDs(
|
||||
ctx,
|
||||
0,
|
||||
true,
|
||||
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
|
||||
OrderDesc().
|
||||
AddQuery().
|
||||
AggregateTypes("instance").
|
||||
EventTypes(instance.InstanceAddedEventType).
|
||||
Builder(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
ctx := authz.WithInstanceID(ctx, instance)
|
||||
if err := projection.ProjectGrantFields.Trigger(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mig *FillFieldsForProjectGrant) String() string {
|
||||
return "29_init_fields_for_project_grant"
|
||||
}
|
42
cmd/setup/30.go
Normal file
42
cmd/setup/30.go
Normal file
@ -0,0 +1,42 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
"github.com/zitadel/zitadel/internal/repository/instance"
|
||||
)
|
||||
|
||||
type FillFieldsForOrgDomainVerified struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
}
|
||||
|
||||
func (mig *FillFieldsForOrgDomainVerified) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
instances, err := mig.eventstore.InstanceIDs(
|
||||
ctx,
|
||||
0,
|
||||
true,
|
||||
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
|
||||
OrderDesc().
|
||||
AddQuery().
|
||||
AggregateTypes("instance").
|
||||
EventTypes(instance.InstanceAddedEventType).
|
||||
Builder(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
ctx := authz.WithInstanceID(ctx, instance)
|
||||
if err := projection.OrgDomainVerifiedFields.Trigger(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mig *FillFieldsForOrgDomainVerified) String() string {
|
||||
return "30_fill_fields_for_org_domain_verified"
|
||||
}
|
27
cmd/setup/31.go
Normal file
27
cmd/setup/31.go
Normal file
@ -0,0 +1,27 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed 31.sql
|
||||
addAggregateIndexToFields string
|
||||
)
|
||||
|
||||
type AddAggregateIndexToFields struct {
|
||||
dbClient *database.DB
|
||||
}
|
||||
|
||||
func (mig *AddAggregateIndexToFields) Execute(ctx context.Context, _ eventstore.Event) error {
|
||||
_, err := mig.dbClient.ExecContext(ctx, addAggregateIndexToFields)
|
||||
return err
|
||||
}
|
||||
|
||||
func (mig *AddAggregateIndexToFields) String() string {
|
||||
return "31_add_aggregate_index_to_fields"
|
||||
}
|
1
cmd/setup/31.sql
Normal file
1
cmd/setup/31.sql
Normal file
@ -0,0 +1 @@
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS f_aggregate_object_type_idx ON eventstore.fields (aggregate_type, aggregate_id, object_type);
|
@ -12,13 +12,14 @@ import (
|
||||
"github.com/zitadel/zitadel/cmd/encryption"
|
||||
"github.com/zitadel/zitadel/cmd/hooks"
|
||||
"github.com/zitadel/zitadel/internal/actions"
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/config/hook"
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
"github.com/zitadel/zitadel/internal/database"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/id"
|
||||
"github.com/zitadel/zitadel/internal/notification/handlers"
|
||||
@ -27,9 +28,10 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ForMirror bool
|
||||
Database database.Config
|
||||
SystemDefaults systemdefaults.SystemDefaults
|
||||
InternalAuthZ authz.Config
|
||||
InternalAuthZ internal_authz.Config
|
||||
ExternalDomain string
|
||||
ExternalPort uint16
|
||||
ExternalSecure bool
|
||||
@ -46,7 +48,7 @@ type Config struct {
|
||||
Login login.Config
|
||||
WebAuthNName string
|
||||
Telemetry *handlers.TelemetryPusherConfig
|
||||
SystemAPIUsers map[string]*authz.SystemAPIUser
|
||||
SystemAPIUsers map[string]*internal_authz.SystemAPIUser
|
||||
}
|
||||
|
||||
type InitProjections struct {
|
||||
@ -60,15 +62,18 @@ func MustNewConfig(v *viper.Viper) *Config {
|
||||
config := new(Config)
|
||||
err := v.Unmarshal(config,
|
||||
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
|
||||
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
|
||||
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
|
||||
hooks.MapHTTPHeaderStringDecode,
|
||||
database.DecodeHook,
|
||||
actions.HTTPConfigDecodeHook,
|
||||
hook.EnumHookFunc(internal_authz.MemberTypeString),
|
||||
hook.Base64ToBytesHookFunc(),
|
||||
hook.TagToLanguageHookFunc(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToTimeHookFunc(time.RFC3339),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
database.DecodeHook,
|
||||
hook.EnumHookFunc(authz.MemberTypeString),
|
||||
actions.HTTPConfigDecodeHook,
|
||||
hooks.MapTypeStringDecode[string, *authz.SystemAPIUser],
|
||||
)),
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to read default config")
|
||||
@ -82,26 +87,34 @@ func MustNewConfig(v *viper.Viper) *Config {
|
||||
}
|
||||
|
||||
type Steps struct {
|
||||
s1ProjectionTable *ProjectionTable
|
||||
s2AssetsTable *AssetTable
|
||||
FirstInstance *FirstInstance
|
||||
s5LastFailed *LastFailed
|
||||
s6OwnerRemoveColumns *OwnerRemoveColumns
|
||||
s7LogstoreTables *LogstoreTables
|
||||
s8AuthTokens *AuthTokenIndexes
|
||||
CorrectCreationDate *CorrectCreationDate
|
||||
s12AddOTPColumns *AddOTPColumns
|
||||
s13FixQuotaProjection *FixQuotaConstraints
|
||||
s14NewEventsTable *NewEventsTable
|
||||
s15CurrentStates *CurrentProjectionState
|
||||
s16UniqueConstraintsLower *UniqueConstraintToLower
|
||||
s17AddOffsetToUniqueConstraints *AddOffsetToCurrentStates
|
||||
s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames
|
||||
s19AddCurrentStatesIndex *AddCurrentSequencesIndex
|
||||
s20AddByUserSessionIndex *AddByUserIndexToSession
|
||||
s21AddBlockFieldToLimits *AddBlockFieldToLimits
|
||||
s22ActiveInstancesIndex *ActiveInstanceEvents
|
||||
s23CorrectGlobalUniqueConstraints *CorrectGlobalUniqueConstraints
|
||||
s1ProjectionTable *ProjectionTable
|
||||
s2AssetsTable *AssetTable
|
||||
FirstInstance *FirstInstance
|
||||
s5LastFailed *LastFailed
|
||||
s6OwnerRemoveColumns *OwnerRemoveColumns
|
||||
s7LogstoreTables *LogstoreTables
|
||||
s8AuthTokens *AuthTokenIndexes
|
||||
CorrectCreationDate *CorrectCreationDate
|
||||
s12AddOTPColumns *AddOTPColumns
|
||||
s13FixQuotaProjection *FixQuotaConstraints
|
||||
s14NewEventsTable *NewEventsTable
|
||||
s15CurrentStates *CurrentProjectionState
|
||||
s16UniqueConstraintsLower *UniqueConstraintToLower
|
||||
s17AddOffsetToUniqueConstraints *AddOffsetToCurrentStates
|
||||
s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames
|
||||
s19AddCurrentStatesIndex *AddCurrentSequencesIndex
|
||||
s20AddByUserSessionIndex *AddByUserIndexToSession
|
||||
s21AddBlockFieldToLimits *AddBlockFieldToLimits
|
||||
s22ActiveInstancesIndex *ActiveInstanceEvents
|
||||
s23CorrectGlobalUniqueConstraints *CorrectGlobalUniqueConstraints
|
||||
s24AddActorToAuthTokens *AddActorToAuthTokens
|
||||
s25User11AddLowerFieldsToVerifiedEmail *User11AddLowerFieldsToVerifiedEmail
|
||||
s26AuthUsers3 *AuthUsers3
|
||||
s27IDPTemplate6SAMLNameIDFormat *IDPTemplate6SAMLNameIDFormat
|
||||
s28AddFieldTable *AddFieldTable
|
||||
s29FillFieldsForProjectGrant *FillFieldsForProjectGrant
|
||||
s30FillFieldsForOrgDomainVerified *FillFieldsForOrgDomainVerified
|
||||
s31AddAggregateIndexToFields *AddAggregateIndexToFields
|
||||
}
|
||||
|
||||
func MustNewSteps(v *viper.Viper) *Steps {
|
||||
|
245
cmd/setup/config_test.go
Normal file
245
cmd/setup/config_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
||||
func TestMustNewConfig(t *testing.T) {
|
||||
encodedKey := "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type args struct {
|
||||
yaml string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want func(*testing.T, *Config)
|
||||
}{{
|
||||
name: "features ok",
|
||||
args: args{yaml: `
|
||||
DefaultInstance:
|
||||
Features:
|
||||
LoginDefaultOrg: true
|
||||
LegacyIntrospection: true
|
||||
TriggerIntrospectionProjections: true
|
||||
UserSchema: true
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{
|
||||
LoginDefaultOrg: gu.Ptr(true),
|
||||
LegacyIntrospection: gu.Ptr(true),
|
||||
TriggerIntrospectionProjections: gu.Ptr(true),
|
||||
UserSchema: gu.Ptr(true),
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "system api users ok",
|
||||
args: args{yaml: `
|
||||
SystemAPIUsers:
|
||||
- superuser:
|
||||
Memberships:
|
||||
- MemberType: System
|
||||
- MemberType: Organization
|
||||
- MemberType: IAM
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.SystemAPIUsers, map[string]*authz.SystemAPIUser{
|
||||
"superuser": {
|
||||
Memberships: authz.Memberships{{
|
||||
MemberType: authz.MemberTypeSystem,
|
||||
}, {
|
||||
MemberType: authz.MemberTypeOrganization,
|
||||
}, {
|
||||
MemberType: authz.MemberTypeIAM,
|
||||
}},
|
||||
},
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "system api users string ok",
|
||||
args: args{yaml: fmt.Sprintf(`
|
||||
SystemAPIUsers: >
|
||||
{"systemuser": {"path": "/path/to/superuser/key.pem"}, "systemuser2": {"keyData": "%s"}}
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`, encodedKey)},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.SystemAPIUsers, map[string]*authz.SystemAPIUser{
|
||||
"systemuser": {
|
||||
Path: "/path/to/superuser/key.pem",
|
||||
},
|
||||
"systemuser2": {
|
||||
KeyData: decodedKey,
|
||||
},
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "headers ok",
|
||||
args: args{yaml: `
|
||||
Telemetry:
|
||||
Headers:
|
||||
single-value: single-value
|
||||
multi-value:
|
||||
- multi-value1
|
||||
- multi-value2
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.Telemetry.Headers, http.Header{
|
||||
"single-value": []string{"single-value"},
|
||||
"multi-value": []string{"multi-value1", "multi-value2"},
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "headers string ok",
|
||||
args: args{yaml: `
|
||||
Telemetry:
|
||||
Headers: >
|
||||
{"single-value": "single-value", "multi-value": ["multi-value1", "multi-value2"]}
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.Telemetry.Headers, http.Header{
|
||||
"single-value": []string{"single-value"},
|
||||
"multi-value": []string{"multi-value1", "multi-value2"},
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "message texts ok",
|
||||
args: args{yaml: `
|
||||
DefaultInstance:
|
||||
MessageTexts:
|
||||
- MessageTextType: InitCode
|
||||
Title: foo
|
||||
- MessageTextType: PasswordReset
|
||||
Greeting: bar
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.DefaultInstance.MessageTexts, []*domain.CustomMessageText{{
|
||||
MessageTextType: "InitCode",
|
||||
Title: "foo",
|
||||
}, {
|
||||
MessageTextType: "PasswordReset",
|
||||
Greeting: "bar",
|
||||
}})
|
||||
},
|
||||
}, {
|
||||
name: "message texts string ok",
|
||||
args: args{yaml: `
|
||||
DefaultInstance:
|
||||
MessageTexts: >
|
||||
[{"messageTextType": "InitCode", "title": "foo"}, {"messageTextType": "PasswordReset", "greeting": "bar"}]
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.DefaultInstance.MessageTexts, []*domain.CustomMessageText{{
|
||||
MessageTextType: "InitCode",
|
||||
Title: "foo",
|
||||
}, {
|
||||
MessageTextType: "PasswordReset",
|
||||
Greeting: "bar",
|
||||
}})
|
||||
},
|
||||
}, {
|
||||
name: "roles ok",
|
||||
args: args{yaml: `
|
||||
InternalAuthZ:
|
||||
RolePermissionMappings:
|
||||
- Role: IAM_OWNER
|
||||
Permissions:
|
||||
- iam.write
|
||||
- Role: ORG_OWNER
|
||||
Permissions:
|
||||
- org.write
|
||||
- org.read
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.InternalAuthZ, authz.Config{
|
||||
RolePermissionMappings: []authz.RoleMapping{
|
||||
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
|
||||
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
|
||||
},
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "roles string ok",
|
||||
args: args{yaml: `
|
||||
InternalAuthZ:
|
||||
RolePermissionMappings: >
|
||||
[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write", "org.read"]}]
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.InternalAuthZ, authz.Config{
|
||||
RolePermissionMappings: []authz.RoleMapping{
|
||||
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
|
||||
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
|
||||
},
|
||||
})
|
||||
},
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
v := viper.New()
|
||||
v.SetConfigType("yaml")
|
||||
require.NoError(t, v.ReadConfig(strings.NewReader(tt.args.yaml)))
|
||||
got := MustNewConfig(v)
|
||||
tt.want(t, got)
|
||||
})
|
||||
}
|
||||
}
|
@ -34,6 +34,8 @@ import (
|
||||
notify_handler "github.com/zitadel/zitadel/internal/notification"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/query/projection"
|
||||
es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore"
|
||||
es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
|
||||
"github.com/zitadel/zitadel/internal/webauthn"
|
||||
)
|
||||
|
||||
@ -57,13 +59,16 @@ Requirements:
|
||||
err = BindInitProjections(cmd)
|
||||
logging.OnError(err).Fatal("unable to bind \"init-projections\" flag")
|
||||
|
||||
err = bindForMirror(cmd)
|
||||
logging.OnError(err).Fatal("unable to bind \"for-mirror\" flag")
|
||||
|
||||
config := MustNewConfig(viper.GetViper())
|
||||
steps := MustNewSteps(viper.New())
|
||||
|
||||
masterKey, err := key.MasterKey(cmd)
|
||||
logging.OnError(err).Panic("No master key provided")
|
||||
|
||||
Setup(config, steps, masterKey)
|
||||
Setup(cmd.Context(), config, steps, masterKey)
|
||||
},
|
||||
}
|
||||
|
||||
@ -77,6 +82,7 @@ Requirements:
|
||||
func Flags(cmd *cobra.Command) {
|
||||
cmd.PersistentFlags().StringArrayVar(&stepFiles, "steps", nil, "paths to step files to overwrite default steps")
|
||||
cmd.Flags().Bool("init-projections", viper.GetBool("InitProjections"), "beta feature: initializes projections after they are created, allows smooth start as projections are up to date")
|
||||
cmd.Flags().Bool("for-mirror", viper.GetBool("ForMirror"), "use this flag if you want to mirror your existing data")
|
||||
key.AddMasterKeyFlag(cmd)
|
||||
tls.AddTLSModeFlag(cmd)
|
||||
}
|
||||
@ -85,8 +91,11 @@ func BindInitProjections(cmd *cobra.Command) error {
|
||||
return viper.BindPFlag("InitProjections.Enabled", cmd.Flags().Lookup("init-projections"))
|
||||
}
|
||||
|
||||
func Setup(config *Config, steps *Steps, masterKey string) {
|
||||
ctx := context.Background()
|
||||
func bindForMirror(cmd *cobra.Command) error {
|
||||
return viper.BindPFlag("ForMirror", cmd.Flags().Lookup("for-mirror"))
|
||||
}
|
||||
|
||||
func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) {
|
||||
logging.Info("setup started")
|
||||
|
||||
i18n.MustLoadSupportedLanguagesFromDir()
|
||||
@ -99,13 +108,20 @@ func Setup(config *Config, steps *Steps, masterKey string) {
|
||||
logging.OnError(err).Fatal("unable to connect to database")
|
||||
|
||||
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
|
||||
esV3 := new_es.NewEventstore(esPusherDBClient)
|
||||
config.Eventstore.Pusher = esV3
|
||||
config.Eventstore.Searcher = esV3
|
||||
eventstoreClient := eventstore.NewEventstore(config.Eventstore)
|
||||
|
||||
logging.OnError(err).Fatal("unable to start eventstore")
|
||||
eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{
|
||||
MaxRetries: config.Eventstore.MaxRetries,
|
||||
}))
|
||||
|
||||
steps.s1ProjectionTable = &ProjectionTable{dbClient: queryDBClient.DB}
|
||||
steps.s2AssetsTable = &AssetTable{dbClient: queryDBClient.DB}
|
||||
|
||||
steps.FirstInstance.Skip = config.ForMirror || steps.FirstInstance.Skip
|
||||
steps.FirstInstance.instanceSetup = config.DefaultInstance
|
||||
steps.FirstInstance.userEncryptionKey = config.EncryptionKeys.User
|
||||
steps.FirstInstance.smtpEncryptionKey = config.EncryptionKeys.SMTP
|
||||
@ -136,6 +152,14 @@ func Setup(config *Config, steps *Steps, masterKey string) {
|
||||
steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient}
|
||||
steps.s22ActiveInstancesIndex = &ActiveInstanceEvents{dbClient: queryDBClient}
|
||||
steps.s23CorrectGlobalUniqueConstraints = &CorrectGlobalUniqueConstraints{dbClient: esPusherDBClient}
|
||||
steps.s24AddActorToAuthTokens = &AddActorToAuthTokens{dbClient: queryDBClient}
|
||||
steps.s25User11AddLowerFieldsToVerifiedEmail = &User11AddLowerFieldsToVerifiedEmail{dbClient: esPusherDBClient}
|
||||
steps.s26AuthUsers3 = &AuthUsers3{dbClient: esPusherDBClient}
|
||||
steps.s27IDPTemplate6SAMLNameIDFormat = &IDPTemplate6SAMLNameIDFormat{dbClient: esPusherDBClient}
|
||||
steps.s28AddFieldTable = &AddFieldTable{dbClient: esPusherDBClient}
|
||||
steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient}
|
||||
steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient}
|
||||
steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient}
|
||||
|
||||
err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil)
|
||||
logging.OnError(err).Fatal("unable to start projections")
|
||||
@ -158,6 +182,8 @@ func Setup(config *Config, steps *Steps, masterKey string) {
|
||||
steps.s14NewEventsTable,
|
||||
steps.s1ProjectionTable,
|
||||
steps.s2AssetsTable,
|
||||
steps.s28AddFieldTable,
|
||||
steps.s31AddAggregateIndexToFields,
|
||||
steps.FirstInstance,
|
||||
steps.s5LastFailed,
|
||||
steps.s6OwnerRemoveColumns,
|
||||
@ -172,6 +198,10 @@ func Setup(config *Config, steps *Steps, masterKey string) {
|
||||
steps.s20AddByUserSessionIndex,
|
||||
steps.s22ActiveInstancesIndex,
|
||||
steps.s23CorrectGlobalUniqueConstraints,
|
||||
steps.s24AddActorToAuthTokens,
|
||||
steps.s26AuthUsers3,
|
||||
steps.s29FillFieldsForProjectGrant,
|
||||
steps.s30FillFieldsForOrgDomainVerified,
|
||||
} {
|
||||
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
|
||||
}
|
||||
@ -184,15 +214,18 @@ func Setup(config *Config, steps *Steps, masterKey string) {
|
||||
for _, step := range []migration.Migration{
|
||||
steps.s18AddLowerFieldsToLoginNames,
|
||||
steps.s21AddBlockFieldToLimits,
|
||||
steps.s25User11AddLowerFieldsToVerifiedEmail,
|
||||
steps.s27IDPTemplate6SAMLNameIDFormat,
|
||||
} {
|
||||
mustExecuteMigration(ctx, eventstoreClient, step, "migration failed")
|
||||
}
|
||||
|
||||
// projection initialization must be done last, since the steps above might add required columns to the projections
|
||||
if config.InitProjections.Enabled {
|
||||
if !config.ForMirror && config.InitProjections.Enabled {
|
||||
initProjections(
|
||||
ctx,
|
||||
eventstoreClient,
|
||||
eventstoreV4,
|
||||
queryDBClient,
|
||||
projectionDBClient,
|
||||
masterKey,
|
||||
@ -214,6 +247,7 @@ func readStmt(fs embed.FS, folder, typ, filename string) (string, error) {
|
||||
func initProjections(
|
||||
ctx context.Context,
|
||||
eventstoreClient *eventstore.Eventstore,
|
||||
eventstoreV4 *es_v4.EventStore,
|
||||
queryDBClient,
|
||||
projectionDBClient *database.DB,
|
||||
masterKey string,
|
||||
@ -270,6 +304,7 @@ func initProjections(
|
||||
queries, err := query.StartQueries(
|
||||
ctx,
|
||||
eventstoreClient,
|
||||
eventstoreV4.Querier,
|
||||
queryDBClient,
|
||||
projectionDBClient,
|
||||
config.Projections,
|
||||
|
@ -1,5 +1,7 @@
|
||||
# By using the FirstInstance section, you can overwrite the DefaultInstance configuration for the first instance created by zitadel setup.
|
||||
FirstInstance:
|
||||
# If set to true zitadel is setup without initial data
|
||||
Skip: false
|
||||
# The machine key from the section FirstInstance.Org.Machine.MachineKey is written to the MachineKeyPath.
|
||||
MachineKeyPath: # ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH
|
||||
# The personal access token from the section FirstInstance.Org.Machine.Pat is written to the PatPath.
|
||||
@ -9,8 +11,7 @@ FirstInstance:
|
||||
Org:
|
||||
Name: ZITADEL # ZITADEL_FIRSTINSTANCE_ORG_NAME
|
||||
# In the FirstInstance.Org.Human section, the initial organization's admin user with the role IAM_OWNER is defined.
|
||||
# ZITADEL either creates a human user or a machine user.
|
||||
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role, not a human user.
|
||||
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role.
|
||||
Human:
|
||||
# In case UserLoginMustBeDomain is false (default) and you don't overwrite the username with an email,
|
||||
# it will be suffixed by the org domain (org-name + domain from config).
|
||||
@ -32,8 +33,7 @@ FirstInstance:
|
||||
Password: Password1! # ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD
|
||||
PasswordChangeRequired: true # ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED
|
||||
# In the FirstInstance.Org.Machine section, the initial organization's admin user with the role IAM_OWNER is defined.
|
||||
# ZITADEL either creates a human user or a machine user.
|
||||
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role, not a human user.
|
||||
# If FirstInstance.Org.Machine.Machine is defined, a service user is created with the IAM_OWNER role.
|
||||
Machine:
|
||||
Machine:
|
||||
Username: # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME
|
||||
@ -52,3 +52,6 @@ CorrectCreationDate:
|
||||
|
||||
AddEventCreatedAt:
|
||||
BulkAmount: 100 # ZITADEL_ADDEVENTCREATEDAT_BULKAMOUNT
|
||||
|
||||
FillFields:
|
||||
BatchSize: 1000 # ZITADEL_EVENTSTORE_FILLFIELDS_BULKLIMIT
|
@ -85,19 +85,19 @@ func MustNewConfig(v *viper.Viper) *Config {
|
||||
err := v.Unmarshal(config,
|
||||
viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||
hooks.SliceTypeStringDecode[*domain.CustomMessageText],
|
||||
hooks.SliceTypeStringDecode[*command.SetQuota],
|
||||
hooks.SliceTypeStringDecode[internal_authz.RoleMapping],
|
||||
hooks.MapTypeStringDecode[string, *internal_authz.SystemAPIUser],
|
||||
hooks.MapTypeStringDecode[domain.Feature, any],
|
||||
hooks.MapHTTPHeaderStringDecode,
|
||||
database.DecodeHook,
|
||||
actions.HTTPConfigDecodeHook,
|
||||
hook.EnumHookFunc(internal_authz.MemberTypeString),
|
||||
hooks.MapTypeStringDecode[domain.Feature, any],
|
||||
hooks.SliceTypeStringDecode[*command.SetQuota],
|
||||
hook.Base64ToBytesHookFunc(),
|
||||
hook.TagToLanguageHookFunc(),
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToTimeHookFunc(time.RFC3339),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
database.DecodeHook,
|
||||
actions.HTTPConfigDecodeHook,
|
||||
hook.EnumHookFunc(internal_authz.MemberTypeString),
|
||||
)),
|
||||
)
|
||||
logging.OnError(err).Fatal("unable to read config")
|
||||
|
@ -223,6 +223,52 @@ Actions:
|
||||
Greeting: "bar",
|
||||
}})
|
||||
},
|
||||
}, {
|
||||
name: "roles ok",
|
||||
args: args{yaml: `
|
||||
InternalAuthZ:
|
||||
RolePermissionMappings:
|
||||
- Role: IAM_OWNER
|
||||
Permissions:
|
||||
- iam.write
|
||||
- Role: ORG_OWNER
|
||||
Permissions:
|
||||
- org.write
|
||||
- org.read
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.InternalAuthZ, authz.Config{
|
||||
RolePermissionMappings: []authz.RoleMapping{
|
||||
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
|
||||
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
|
||||
},
|
||||
})
|
||||
},
|
||||
}, {
|
||||
name: "roles string ok",
|
||||
args: args{yaml: `
|
||||
InternalAuthZ:
|
||||
RolePermissionMappings: >
|
||||
[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write", "org.read"]}]
|
||||
Log:
|
||||
Level: info
|
||||
Actions:
|
||||
HTTP:
|
||||
DenyList: []
|
||||
`},
|
||||
want: func(t *testing.T, config *Config) {
|
||||
assert.Equal(t, config.InternalAuthZ, authz.Config{
|
||||
RolePermissionMappings: []authz.RoleMapping{
|
||||
{Role: "IAM_OWNER", Permissions: []string{"iam.write"}},
|
||||
{Role: "ORG_OWNER", Permissions: []string{"org.write", "org.read"}},
|
||||
},
|
||||
})
|
||||
},
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
@ -4,7 +4,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/cmd/key"
|
||||
|
@ -34,18 +34,24 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api"
|
||||
"github.com/zitadel/zitadel/internal/api/assets"
|
||||
internal_authz "github.com/zitadel/zitadel/internal/api/authz"
|
||||
action_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/action/v3alpha"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/admin"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/auth"
|
||||
execution_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/execution/v3alpha"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/feature/v2"
|
||||
feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2"
|
||||
feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/management"
|
||||
oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/org/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/session/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
|
||||
oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta"
|
||||
org_v2 "github.com/zitadel/zitadel/internal/api/grpc/org/v2"
|
||||
org_v2beta "github.com/zitadel/zitadel/internal/api/grpc/org/v2beta"
|
||||
session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2"
|
||||
session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta"
|
||||
settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
|
||||
settings_v2beta "github.com/zitadel/zitadel/internal/api/grpc/settings/v2beta"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/system"
|
||||
user_schema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/user/schema/v3alpha"
|
||||
user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2"
|
||||
user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta"
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/api/idp"
|
||||
@ -78,6 +84,8 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/notification"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
"github.com/zitadel/zitadel/internal/static"
|
||||
es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore"
|
||||
es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres"
|
||||
"github.com/zitadel/zitadel/internal/webauthn"
|
||||
"github.com/zitadel/zitadel/openapi"
|
||||
)
|
||||
@ -151,14 +159,19 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
}
|
||||
|
||||
config.Eventstore.Pusher = new_es.NewEventstore(esPusherDBClient)
|
||||
config.Eventstore.Searcher = new_es.NewEventstore(queryDBClient)
|
||||
config.Eventstore.Querier = old_es.NewCRDB(queryDBClient)
|
||||
eventstoreClient := eventstore.NewEventstore(config.Eventstore)
|
||||
eventstoreV4 := es_v4.NewEventstoreFromOne(es_v4_pg.New(queryDBClient, &es_v4_pg.Config{
|
||||
MaxRetries: config.Eventstore.MaxRetries,
|
||||
}))
|
||||
|
||||
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
|
||||
|
||||
queries, err := query.StartQueries(
|
||||
ctx,
|
||||
eventstoreClient,
|
||||
eventstoreV4.Querier,
|
||||
queryDBClient,
|
||||
projectionDBClient,
|
||||
config.Projections,
|
||||
@ -392,23 +405,37 @@ func startAPIs(
|
||||
if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure), tlsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil {
|
||||
if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
|
||||
if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil {
|
||||
if err := apis.RegisterService(ctx, org_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, feature.CreateServer(commands, queries)); err != nil {
|
||||
if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, execution_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
|
||||
if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, org_v2.CreateServer(commands, queries, permissionCheck)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, feature_v2.CreateServer(commands, queries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, action_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, user_schema_v3_alpha.CreateServer(commands, queries)); err != nil {
|
||||
@ -439,7 +466,7 @@ func startAPIs(
|
||||
}
|
||||
apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler)
|
||||
|
||||
oidcServer, err := oidc.NewServer(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, config.Log.Slog())
|
||||
oidcServer, err := oidc.NewServer(ctx, config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, config.Log.Slog(), config.SystemDefaults.SecretHasher)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to start oidc provider: %w", err)
|
||||
}
|
||||
@ -484,6 +511,9 @@ func startAPIs(
|
||||
apis.HandleFunc(login.EndpointDeviceAuth, login.RedirectDeviceAuthToPrefix)
|
||||
|
||||
// After OIDC provider so that the callback endpoint can be used
|
||||
if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -544,15 +574,17 @@ func showBasicInformation(startConfig *Config) {
|
||||
|
||||
consoleURL := fmt.Sprintf("%s://%s:%v/ui/console\n", http, startConfig.ExternalDomain, startConfig.ExternalPort)
|
||||
healthCheckURL := fmt.Sprintf("%s://%s:%v/debug/healthz\n", http, startConfig.ExternalDomain, startConfig.ExternalPort)
|
||||
machineIdMethod := id.MachineIdentificationMethod()
|
||||
|
||||
insecure := !startConfig.TLS.Enabled && !startConfig.ExternalSecure
|
||||
|
||||
fmt.Printf(" ===============================================================\n\n")
|
||||
fmt.Printf(" Version : %s\n", build.Version())
|
||||
fmt.Printf(" TLS enabled : %v\n", startConfig.TLS.Enabled)
|
||||
fmt.Printf(" External Secure : %v\n", startConfig.ExternalSecure)
|
||||
fmt.Printf(" Console URL : %s", color.BlueString(consoleURL))
|
||||
fmt.Printf(" Health Check URL : %s", color.BlueString(healthCheckURL))
|
||||
fmt.Printf(" Version : %s\n", build.Version())
|
||||
fmt.Printf(" TLS enabled : %v\n", startConfig.TLS.Enabled)
|
||||
fmt.Printf(" External Secure : %v\n", startConfig.ExternalSecure)
|
||||
fmt.Printf(" Machine Id Method : %v\n", machineIdMethod)
|
||||
fmt.Printf(" Console URL : %s", color.BlueString(consoleURL))
|
||||
fmt.Printf(" Health Check URL : %s", color.BlueString(healthCheckURL))
|
||||
if insecure {
|
||||
fmt.Printf("\n %s: you're using plain http without TLS. Be aware this is \n", color.RedString("Warning"))
|
||||
fmt.Printf(" not a secure setup and should only be used for test systems. \n")
|
||||
|
@ -36,7 +36,7 @@ Requirements:
|
||||
|
||||
setupConfig := setup.MustNewConfig(viper.GetViper())
|
||||
setupSteps := setup.MustNewSteps(viper.New())
|
||||
setup.Setup(setupConfig, setupSteps, masterKey)
|
||||
setup.Setup(cmd.Context(), setupConfig, setupSteps, masterKey)
|
||||
|
||||
startConfig := MustNewConfig(viper.GetViper())
|
||||
|
||||
|
@ -34,7 +34,7 @@ Requirements:
|
||||
|
||||
setupConfig := setup.MustNewConfig(viper.GetViper())
|
||||
setupSteps := setup.MustNewSteps(viper.New())
|
||||
setup.Setup(setupConfig, setupSteps, masterKey)
|
||||
setup.Setup(cmd.Context(), setupConfig, setupSteps, masterKey)
|
||||
|
||||
startConfig := MustNewConfig(viper.GetViper())
|
||||
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"github.com/zitadel/zitadel/cmd/build"
|
||||
"github.com/zitadel/zitadel/cmd/initialise"
|
||||
"github.com/zitadel/zitadel/cmd/key"
|
||||
"github.com/zitadel/zitadel/cmd/mirror"
|
||||
"github.com/zitadel/zitadel/cmd/ready"
|
||||
"github.com/zitadel/zitadel/cmd/setup"
|
||||
"github.com/zitadel/zitadel/cmd/start"
|
||||
@ -55,6 +56,7 @@ func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server
|
||||
start.New(server),
|
||||
start.NewStartFromInit(server),
|
||||
start.NewStartFromSetup(server),
|
||||
mirror.New(&configFiles),
|
||||
key.New(),
|
||||
ready.New(),
|
||||
)
|
||||
|
@ -159,6 +159,8 @@ export class AppComponent implements OnDestroy {
|
||||
|
||||
this.matIconRegistry.addSvgIcon('mdi_jwt', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/jwt.svg'));
|
||||
|
||||
this.matIconRegistry.addSvgIcon('mdi_smtp', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/mail.svg'));
|
||||
|
||||
this.matIconRegistry.addSvgIcon('mdi_symbol', this.domSanitizer.bypassSecurityTrustResourceUrl('assets/mdi/symbol.svg'));
|
||||
|
||||
this.matIconRegistry.addSvgIcon(
|
||||
|
@ -14,6 +14,7 @@ import localePt from '@angular/common/locales/pt';
|
||||
import localeZh from '@angular/common/locales/zh';
|
||||
import localeRu from '@angular/common/locales/ru';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
import localeSv from '@angular/common/locales/sv';
|
||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
@ -99,6 +100,8 @@ registerLocaleData(localeCs);
|
||||
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/cs.json'));
|
||||
registerLocaleData(localeNl);
|
||||
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/nl.json'));
|
||||
registerLocaleData(localeSv);
|
||||
i18nIsoCountries.registerLocale(require('i18n-iso-countries/langs/sv.json'));
|
||||
|
||||
export class WebpackTranslateLoader implements TranslateLoader {
|
||||
getTranslation(lang: string): Observable<any> {
|
||||
|
16
console/src/app/components/copy-row/copy-row.component.html
Normal file
16
console/src/app/components/copy-row/copy-row.component.html
Normal file
@ -0,0 +1,16 @@
|
||||
<div class="cnsl-cr-row">
|
||||
<div class="cnsl-cr-secondary-text" [ngStyle]="{ minWidth: labelMinWidth }">{{ label }}</div>
|
||||
<button
|
||||
class="cnsl-cr-copy"
|
||||
[disabled]="copied === value"
|
||||
[matTooltip]="(copied !== value ? 'ACTIONS.COPY' : 'ACTIONS.COPIED') | translate"
|
||||
cnslCopyToClipboard
|
||||
[valueToCopy]="value"
|
||||
(copiedValue)="copied = $event"
|
||||
>
|
||||
{{ value }}
|
||||
</button>
|
||||
<div class="cnsl-cr-item">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
41
console/src/app/components/copy-row/copy-row.component.scss
Normal file
41
console/src/app/components/copy-row/copy-row.component.scss
Normal file
@ -0,0 +1,41 @@
|
||||
.cnsl-cr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.cnsl-cr-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin copy-row-theme($theme) {
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
$foreground: map-get($theme, foreground);
|
||||
$button-text-color: map-get($foreground, text);
|
||||
$button-disabled-text-color: map-get($foreground, disabled-button);
|
||||
|
||||
.cnsl-cr-copy {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
background-color: #8795a110;
|
||||
border: 1px solid #8795a160;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 1rem;
|
||||
margin: 0.25rem 0rem;
|
||||
color: $button-text-color;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
cursor: copy;
|
||||
|
||||
&[disabled] {
|
||||
color: $button-disabled-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
21
console/src/app/components/copy-row/copy-row.component.ts
Normal file
21
console/src/app/components/copy-row/copy-row.component.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { CopyToClipboardModule } from '../../directives/copy-to-clipboard/copy-to-clipboard.module';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'cnsl-copy-row',
|
||||
templateUrl: './copy-row.component.html',
|
||||
styleUrls: ['./copy-row.component.scss'],
|
||||
imports: [CommonModule, TranslateModule, MatButtonModule, MatTooltipModule, CopyToClipboardModule],
|
||||
})
|
||||
export class CopyRowComponent {
|
||||
@Input({ required: true }) public label = '';
|
||||
@Input({ required: true }) public value = '';
|
||||
@Input() public labelMinWidth = '';
|
||||
|
||||
public copied = '';
|
||||
}
|
359
console/src/app/components/features/features.component.html
Normal file
359
console/src/app/components/features/features.component.html
Normal file
@ -0,0 +1,359 @@
|
||||
<div class="feature-settings-wrapper">
|
||||
<div class="feature-title-row">
|
||||
<h2>{{ 'DESCRIPTIONS.SETTINGS.FEATURES.TITLE' | translate }}</h2>
|
||||
<a
|
||||
mat-icon-button
|
||||
href="https://zitadel.com/docs/apis/resources/feature_service_v2/feature-service"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<mat-icon class="icon">info_outline</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
<p class="events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.FEATURES.DESCRIPTION' | translate }}</p>
|
||||
|
||||
<ng-template cnslHasRole [hasRole]="['iam.restrictions.write']">
|
||||
<button color="warn" (click)="resetSettings()" mat-stroked-button>
|
||||
{{ 'SETTING.FEATURES.RESET' | translate }}
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<cnsl-card *ngIf="toggleStates && featureData">
|
||||
<div class="features">
|
||||
<div class="feature-row" *ngIf="toggleStates.loginDefaultOrg">
|
||||
<span>{{ 'SETTING.FEATURES.LOGINDEFAULTORG' | translate }}</span>
|
||||
<div class="row">
|
||||
<mat-button-toggle-group
|
||||
class="theme-toggle"
|
||||
class="buttongroup"
|
||||
[(ngModel)]="toggleStates.loginDefaultOrg.state"
|
||||
(change)="validateAndSave()"
|
||||
name="displayview"
|
||||
aria-label="Display View"
|
||||
>
|
||||
<mat-button-toggle [value]="ToggleState.INHERITED">
|
||||
<div class="toggle-row">
|
||||
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
|
||||
<i
|
||||
class="info-i las la-question-circle"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
|
||||
></i>
|
||||
<div
|
||||
*ngIf="
|
||||
!!featureData.loginDefaultOrg?.enabled &&
|
||||
(featureData.loginDefaultOrg?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.loginDefaultOrg?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot enabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="
|
||||
!featureData.loginDefaultOrg?.enabled &&
|
||||
(featureData.loginDefaultOrg?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.loginDefaultOrg?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot disabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
|
||||
></div>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.DISABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.ENABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
|
||||
<cnsl-info-section class="feature-info">{{
|
||||
'SETTING.FEATURES.LOGINDEFAULTORG_DESCRIPTION' | translate
|
||||
}}</cnsl-info-section>
|
||||
</div>
|
||||
|
||||
<div class="feature-row" *ngIf="toggleStates.oidcLegacyIntrospection">
|
||||
<span>{{ 'SETTING.FEATURES.OIDCLEGACYINTROSPECTION' | translate }}</span>
|
||||
<div class="row">
|
||||
<mat-button-toggle-group
|
||||
class="theme-toggle"
|
||||
class="buttongroup"
|
||||
[(ngModel)]="toggleStates.oidcLegacyIntrospection.state"
|
||||
(change)="validateAndSave()"
|
||||
name="displayview"
|
||||
aria-label="Display View"
|
||||
>
|
||||
<mat-button-toggle [value]="ToggleState.INHERITED">
|
||||
<div class="toggle-row">
|
||||
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
|
||||
<i
|
||||
class="info-i las la-question-circle"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
|
||||
></i>
|
||||
<div
|
||||
*ngIf="
|
||||
!!featureData.oidcLegacyIntrospection?.enabled &&
|
||||
(featureData.oidcLegacyIntrospection?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.oidcLegacyIntrospection?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot enabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="
|
||||
!featureData.oidcLegacyIntrospection?.enabled &&
|
||||
(featureData.oidcLegacyIntrospection?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.oidcLegacyIntrospection?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot disabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
|
||||
></div>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.DISABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.ENABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<cnsl-info-section class="feature-info">{{
|
||||
'SETTING.FEATURES.OIDCLEGACYINTROSPECTION_DESCRIPTION' | translate
|
||||
}}</cnsl-info-section>
|
||||
</div>
|
||||
|
||||
<div class="feature-row" *ngIf="toggleStates.oidcTokenExchange">
|
||||
<span>{{ 'SETTING.FEATURES.OIDCTOKENEXCHANGE' | translate }}</span>
|
||||
<div class="row">
|
||||
<mat-button-toggle-group
|
||||
class="theme-toggle"
|
||||
class="buttongroup"
|
||||
[(ngModel)]="toggleStates.oidcTokenExchange.state"
|
||||
(change)="validateAndSave()"
|
||||
name="displayview"
|
||||
aria-label="Display View"
|
||||
>
|
||||
<mat-button-toggle [value]="ToggleState.INHERITED">
|
||||
<div class="toggle-row">
|
||||
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
|
||||
<i
|
||||
class="info-i las la-question-circle"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
|
||||
></i>
|
||||
<div
|
||||
*ngIf="
|
||||
!!featureData.oidcTokenExchange?.enabled &&
|
||||
(featureData.oidcTokenExchange?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.oidcTokenExchange?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot enabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="
|
||||
!featureData.oidcTokenExchange?.enabled &&
|
||||
(featureData.oidcTokenExchange?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.oidcTokenExchange?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot disabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
|
||||
></div>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.DISABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.ENABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<cnsl-info-section class="feature-info">{{
|
||||
'SETTING.FEATURES.OIDCTOKENEXCHANGE_DESCRIPTION' | translate
|
||||
}}</cnsl-info-section>
|
||||
</div>
|
||||
|
||||
<div class="feature-row" *ngIf="toggleStates.oidcTriggerIntrospectionProjections">
|
||||
<span>{{ 'SETTING.FEATURES.OIDCTRIGGERINTROSPECTIONPROJECTIONS' | translate }}</span>
|
||||
<div class="row">
|
||||
<mat-button-toggle-group
|
||||
class="theme-toggle"
|
||||
class="buttongroup"
|
||||
[(ngModel)]="toggleStates.oidcTriggerIntrospectionProjections.state"
|
||||
(change)="validateAndSave()"
|
||||
name="displayview"
|
||||
aria-label="Display View"
|
||||
>
|
||||
<mat-button-toggle [value]="ToggleState.INHERITED">
|
||||
<div class="toggle-row">
|
||||
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
|
||||
<i
|
||||
class="info-i las la-question-circle"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
|
||||
></i>
|
||||
<div
|
||||
*ngIf="
|
||||
!!featureData.oidcTriggerIntrospectionProjections?.enabled &&
|
||||
(featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot enabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="
|
||||
!featureData.oidcTriggerIntrospectionProjections?.enabled &&
|
||||
(featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot disabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
|
||||
></div>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.DISABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.ENABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<cnsl-info-section class="feature-info">{{
|
||||
'SETTING.FEATURES.OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION' | translate
|
||||
}}</cnsl-info-section>
|
||||
</div>
|
||||
|
||||
<div class="feature-row" *ngIf="toggleStates.userSchema">
|
||||
<span>{{ 'SETTING.FEATURES.USERSCHEMA' | translate }}</span>
|
||||
<div class="row">
|
||||
<mat-button-toggle-group
|
||||
class="theme-toggle"
|
||||
class="buttongroup"
|
||||
[(ngModel)]="toggleStates.userSchema.state"
|
||||
(change)="validateAndSave()"
|
||||
name="displayview"
|
||||
aria-label="Display View"
|
||||
>
|
||||
<mat-button-toggle [value]="ToggleState.INHERITED">
|
||||
<div class="toggle-row">
|
||||
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
|
||||
<i
|
||||
class="info-i las la-question-circle"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
|
||||
></i>
|
||||
<div
|
||||
*ngIf="
|
||||
!!featureData.userSchema?.enabled &&
|
||||
(featureData.userSchema?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.userSchema?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot enabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="
|
||||
!featureData.userSchema?.enabled &&
|
||||
(featureData.userSchema?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.userSchema?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot disabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
|
||||
></div>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.DISABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.ENABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<cnsl-info-section class="feature-info">{{
|
||||
'SETTING.FEATURES.USERSCHEMA_DESCRIPTION' | translate
|
||||
}}</cnsl-info-section>
|
||||
</div>
|
||||
|
||||
<div class="feature-row" *ngIf="toggleStates.actions">
|
||||
<span>{{ 'SETTING.FEATURES.ACTIONS' | translate }}</span>
|
||||
<div class="row">
|
||||
<mat-button-toggle-group
|
||||
class="theme-toggle"
|
||||
class="buttongroup"
|
||||
[(ngModel)]="toggleStates.actions.state"
|
||||
(change)="validateAndSave()"
|
||||
name="displayview"
|
||||
aria-label="Display View"
|
||||
>
|
||||
<mat-button-toggle [value]="ToggleState.INHERITED">
|
||||
<div class="toggle-row">
|
||||
<span>{{ 'SETTING.FEATURES.STATES.INHERITED' | translate }}</span>
|
||||
<i
|
||||
class="info-i las la-question-circle"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITED_DESCRIPTION' | translate }}"
|
||||
></i>
|
||||
<div
|
||||
*ngIf="
|
||||
!!featureData.actions?.enabled &&
|
||||
(featureData.actions?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.actions?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot enabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.ENABLED' | translate }}"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="
|
||||
!featureData.actions?.enabled &&
|
||||
(featureData.actions?.source === Source.SOURCE_SYSTEM ||
|
||||
featureData.actions?.source === Source.SOURCE_UNSPECIFIED)
|
||||
"
|
||||
class="current-dot disabled"
|
||||
matTooltip="{{ 'SETTING.FEATURES.INHERITEDINDICATOR_DESCRIPTION.DISABLED' | translate }}"
|
||||
></div>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.DISABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.DISABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle [value]="ToggleState.ENABLED">
|
||||
<div class="toggle-row">
|
||||
<span> {{ 'SETTING.FEATURES.STATES.ENABLED' | translate }}</span>
|
||||
</div>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<cnsl-info-section class="feature-info">{{ 'SETTING.FEATURES.ACTIONS_DESCRIPTION' | translate }}</cnsl-info-section>
|
||||
</div>
|
||||
</div>
|
||||
</cnsl-card>
|
||||
</div>
|
||||
|
||||
<ng-template #sourceLabel let-source="source" let-last="last">
|
||||
<span class="state" *ngIf="source === Source.SOURCE_SYSTEM">
|
||||
{{ 'SETTING.FEATURES.SOURCE.' + source | translate }}
|
||||
</span>
|
||||
</ng-template>
|
69
console/src/app/components/features/features.component.scss
Normal file
69
console/src/app/components/features/features.component.scss
Normal file
@ -0,0 +1,69 @@
|
||||
.feature-settings-wrapper {
|
||||
.feature-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a .icon {
|
||||
font-size: 1.2rem;
|
||||
height: 1.2rem;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
.feature-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.buttongroup {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.info-i {
|
||||
font-size: 1.2rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.current-dot {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 50%;
|
||||
// background-color: rgb(84, 142, 230);
|
||||
margin-left: 0.5rem;
|
||||
|
||||
&.enabled {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background-color: var(--warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feature-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { FeaturesComponent } from './features.component';
|
||||
|
||||
describe('FeaturesComponent', () => {
|
||||
let component: FeaturesComponent;
|
||||
let fixture: ComponentFixture<FeaturesComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [FeaturesComponent],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FeaturesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
255
console/src/app/components/features/features.component.ts
Normal file
255
console/src/app/components/features/features.component.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
|
||||
import { CardModule } from 'src/app/modules/card/card.module';
|
||||
import { DisplayJsonDialogComponent } from 'src/app/modules/display-json-dialog/display-json-dialog.component';
|
||||
import { InfoSectionModule } from 'src/app/modules/info-section/info-section.module';
|
||||
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
|
||||
import { Event } from 'src/app/proto/generated/zitadel/event_pb';
|
||||
import { Source } from 'src/app/proto/generated/zitadel/feature/v2beta/feature_pb';
|
||||
import {
|
||||
GetInstanceFeaturesResponse,
|
||||
SetInstanceFeaturesRequest,
|
||||
} from 'src/app/proto/generated/zitadel/feature/v2beta/instance_pb';
|
||||
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
|
||||
import { FeatureService } from 'src/app/services/feature.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
enum ToggleState {
|
||||
ENABLED = 'ENABLED',
|
||||
DISABLED = 'DISABLED',
|
||||
INHERITED = 'INHERITED',
|
||||
}
|
||||
|
||||
type FeatureState = { source: Source; state: ToggleState };
|
||||
type ToggleStates = {
|
||||
loginDefaultOrg?: FeatureState;
|
||||
oidcTriggerIntrospectionProjections?: FeatureState;
|
||||
oidcLegacyIntrospection?: FeatureState;
|
||||
userSchema?: FeatureState;
|
||||
oidcTokenExchange?: FeatureState;
|
||||
actions?: FeatureState;
|
||||
};
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonToggleModule,
|
||||
HasRolePipeModule,
|
||||
MatIconModule,
|
||||
CardModule,
|
||||
TranslateModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
InfoSectionModule,
|
||||
MatTooltipModule,
|
||||
HasRoleModule,
|
||||
],
|
||||
standalone: true,
|
||||
selector: 'cnsl-features',
|
||||
templateUrl: './features.component.html',
|
||||
styleUrls: ['./features.component.scss'],
|
||||
})
|
||||
export class FeaturesComponent implements OnDestroy {
|
||||
private destroy$: Subject<void> = new Subject();
|
||||
|
||||
public _loading: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
public featureData: GetInstanceFeaturesResponse.AsObject | undefined = undefined;
|
||||
|
||||
public toggleStates: ToggleStates | undefined = undefined;
|
||||
public Source: any = Source;
|
||||
public ToggleState: any = ToggleState;
|
||||
|
||||
constructor(
|
||||
private featureService: FeatureService,
|
||||
private breadcrumbService: BreadcrumbService,
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
) {
|
||||
const breadcrumbs = [
|
||||
new Breadcrumb({
|
||||
type: BreadcrumbType.INSTANCE,
|
||||
name: 'Instance',
|
||||
routerLink: ['/instance'],
|
||||
}),
|
||||
];
|
||||
this.breadcrumbService.setBreadcrumb(breadcrumbs);
|
||||
|
||||
this.getFeatures(true);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
public openDialog(event: Event): void {
|
||||
this.dialog.open(DisplayJsonDialogComponent, {
|
||||
data: {
|
||||
event: event,
|
||||
},
|
||||
width: '450px',
|
||||
});
|
||||
}
|
||||
|
||||
public validateAndSave() {
|
||||
this.featureService.resetInstanceFeatures().then(() => {
|
||||
const req = new SetInstanceFeaturesRequest();
|
||||
let changed = false;
|
||||
|
||||
console.log(this.toggleStates);
|
||||
|
||||
if (this.toggleStates?.loginDefaultOrg?.state !== ToggleState.INHERITED) {
|
||||
req.setLoginDefaultOrg(this.toggleStates?.loginDefaultOrg?.state === ToggleState.ENABLED);
|
||||
changed = true;
|
||||
}
|
||||
if (this.toggleStates?.oidcTriggerIntrospectionProjections?.state !== ToggleState.INHERITED) {
|
||||
req.setOidcTriggerIntrospectionProjections(
|
||||
this.toggleStates?.oidcTriggerIntrospectionProjections?.state === ToggleState.ENABLED,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (this.toggleStates?.oidcLegacyIntrospection?.state !== ToggleState.INHERITED) {
|
||||
req.setOidcLegacyIntrospection(this.toggleStates?.oidcLegacyIntrospection?.state === ToggleState.ENABLED);
|
||||
changed = true;
|
||||
}
|
||||
if (this.toggleStates?.userSchema?.state !== ToggleState.INHERITED) {
|
||||
req.setUserSchema(this.toggleStates?.userSchema?.state === ToggleState.ENABLED);
|
||||
changed = true;
|
||||
}
|
||||
if (this.toggleStates?.oidcTokenExchange?.state !== ToggleState.INHERITED) {
|
||||
req.setOidcTokenExchange(this.toggleStates?.oidcTokenExchange?.state === ToggleState.ENABLED);
|
||||
changed = true;
|
||||
}
|
||||
if (this.toggleStates?.actions?.state !== ToggleState.INHERITED) {
|
||||
req.setActions(this.toggleStates?.actions?.state === ToggleState.ENABLED);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.featureService
|
||||
.setInstanceFeatures(req)
|
||||
.then(() => {
|
||||
this.toast.showInfo('POLICY.TOAST.SET', true);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getFeatures(inheritance: boolean) {
|
||||
this.featureService.getInstanceFeatures(inheritance).then((instanceFeaturesResponse) => {
|
||||
this.featureData = instanceFeaturesResponse.toObject();
|
||||
console.log(this.featureData);
|
||||
|
||||
this.toggleStates = {
|
||||
loginDefaultOrg: {
|
||||
source: this.featureData.loginDefaultOrg?.source || Source.SOURCE_SYSTEM,
|
||||
state:
|
||||
this.featureData.loginDefaultOrg?.source === Source.SOURCE_SYSTEM ||
|
||||
this.featureData.loginDefaultOrg?.source === Source.SOURCE_UNSPECIFIED
|
||||
? ToggleState.INHERITED
|
||||
: !!this.featureData.loginDefaultOrg?.enabled
|
||||
? ToggleState.ENABLED
|
||||
: ToggleState.DISABLED,
|
||||
},
|
||||
oidcTriggerIntrospectionProjections: {
|
||||
source: this.featureData.oidcTriggerIntrospectionProjections?.source || Source.SOURCE_SYSTEM,
|
||||
state:
|
||||
this.featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_SYSTEM ||
|
||||
this.featureData.oidcTriggerIntrospectionProjections?.source === Source.SOURCE_UNSPECIFIED
|
||||
? ToggleState.INHERITED
|
||||
: !!this.featureData.oidcTriggerIntrospectionProjections?.enabled
|
||||
? ToggleState.ENABLED
|
||||
: ToggleState.DISABLED,
|
||||
},
|
||||
oidcLegacyIntrospection: {
|
||||
source: this.featureData.oidcLegacyIntrospection?.source || Source.SOURCE_SYSTEM,
|
||||
state:
|
||||
this.featureData.oidcLegacyIntrospection?.source === Source.SOURCE_SYSTEM ||
|
||||
this.featureData.oidcLegacyIntrospection?.source === Source.SOURCE_UNSPECIFIED
|
||||
? ToggleState.INHERITED
|
||||
: !!this.featureData.oidcLegacyIntrospection?.enabled
|
||||
? ToggleState.ENABLED
|
||||
: ToggleState.DISABLED,
|
||||
},
|
||||
userSchema: {
|
||||
source: this.featureData.userSchema?.source || Source.SOURCE_SYSTEM,
|
||||
state:
|
||||
this.featureData.userSchema?.source === Source.SOURCE_SYSTEM ||
|
||||
this.featureData.userSchema?.source === Source.SOURCE_UNSPECIFIED
|
||||
? ToggleState.INHERITED
|
||||
: !!this.featureData.userSchema?.enabled
|
||||
? ToggleState.ENABLED
|
||||
: ToggleState.DISABLED,
|
||||
},
|
||||
oidcTokenExchange: {
|
||||
source: this.featureData.oidcTokenExchange?.source || Source.SOURCE_SYSTEM,
|
||||
state:
|
||||
this.featureData.oidcTokenExchange?.source === Source.SOURCE_SYSTEM ||
|
||||
this.featureData.oidcTokenExchange?.source === Source.SOURCE_UNSPECIFIED
|
||||
? ToggleState.INHERITED
|
||||
: !!this.featureData.oidcTokenExchange?.enabled
|
||||
? ToggleState.ENABLED
|
||||
: ToggleState.DISABLED,
|
||||
},
|
||||
actions: {
|
||||
source: Source.SOURCE_SYSTEM,
|
||||
state:
|
||||
this.featureData.actions?.source === Source.SOURCE_SYSTEM ||
|
||||
this.featureData.actions?.source === Source.SOURCE_UNSPECIFIED
|
||||
? ToggleState.INHERITED
|
||||
: !!this.featureData.actions?.enabled
|
||||
? ToggleState.ENABLED
|
||||
: ToggleState.DISABLED,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public resetSettings(): void {
|
||||
this.featureService
|
||||
.resetInstanceFeatures()
|
||||
.then(() => {
|
||||
this.toast.showInfo('POLICY.TOAST.RESETSUCCESS', true);
|
||||
setTimeout(() => {
|
||||
this.getFeatures(true);
|
||||
}, 1000);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
});
|
||||
}
|
||||
|
||||
public saveFeatures(): void {
|
||||
if (this.featureData) {
|
||||
const req = new SetInstanceFeaturesRequest();
|
||||
req.setLoginDefaultOrg(!!this.featureData.loginDefaultOrg?.enabled);
|
||||
req.setOidcLegacyIntrospection(!!this.featureData.oidcLegacyIntrospection?.enabled);
|
||||
req.setOidcTokenExchange(!!this.featureData.oidcTokenExchange?.enabled);
|
||||
req.setOidcTriggerIntrospectionProjections(!!this.featureData.oidcTriggerIntrospectionProjections?.enabled);
|
||||
req.setUserSchema(!!this.featureData.userSchema?.enabled);
|
||||
|
||||
this.featureService
|
||||
.setInstanceFeatures(req)
|
||||
.then(() => {
|
||||
this.toast.showInfo('POLICY.TOAST.SET', true);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: '[cnslScrollable]',
|
||||
})
|
||||
export class ScrollableDirective {
|
||||
@ -15,7 +16,6 @@ export class ScrollableDirective {
|
||||
const top = event.target.scrollTop;
|
||||
const height = this.el.nativeElement.scrollHeight;
|
||||
const offset = this.el.nativeElement.offsetHeight;
|
||||
|
||||
// emit bottom event
|
||||
if (top > height - offset - 1) {
|
||||
this.scrollPosition.emit('bottom');
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { ScrollableDirective } from './scrollable.directive';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ScrollableDirective],
|
||||
imports: [CommonModule],
|
||||
exports: [ScrollableDirective],
|
||||
})
|
||||
export class ScrollableModule {}
|
@ -6,7 +6,6 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ScrollableModule } from 'src/app/directives/scrollable/scrollable.module';
|
||||
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
|
||||
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
|
||||
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
|
||||
@ -18,7 +17,6 @@ import { ChangesComponent } from './changes.component';
|
||||
declarations: [ChangesComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ScrollableModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
MatIconModule,
|
||||
@ -30,6 +28,6 @@ import { ChangesComponent } from './changes.component';
|
||||
MatTooltipModule,
|
||||
AvatarModule,
|
||||
],
|
||||
exports: [ChangesComponent, ScrollableModule],
|
||||
exports: [ChangesComponent],
|
||||
})
|
||||
export class ChangesModule {}
|
||||
|
@ -34,7 +34,7 @@
|
||||
<button mat-stroked-button type="button" (click)="reset()">{{ 'ACTIONS.RESET' | translate }}</button>
|
||||
<span class="filter-middle">{{ 'FILTER.TITLE' | translate }}</span>
|
||||
<button mat-raised-button color="primary" type="button" (click)="finish()" data-e2e="filter-finish-button">
|
||||
{{ 'ACTIONS.FINISH' | translate }}
|
||||
{{ 'ACTIONS.APPLY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<form *ngIf="form" [formGroup]="form" (ngSubmit)="emitChange()">
|
||||
|
@ -20,7 +20,7 @@
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
max-width: 450px;
|
||||
padding-bottom: 0.5rem;
|
||||
position: relative;
|
||||
color: map-get($foreground, text);
|
||||
@ -82,7 +82,7 @@
|
||||
}
|
||||
|
||||
.aggregate-type-select {
|
||||
min-width: 100px;
|
||||
min-width: 250px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@
|
||||
<div class="filter-top">
|
||||
<button mat-stroked-button (click)="resetted.emit()">{{ 'ACTIONS.RESET' | translate }}</button>
|
||||
<span class="filter-middle">{{ 'FILTER.TITLE' | translate }}</span>
|
||||
<button mat-raised-button color="primary" (click)="emitFilter()">{{ 'ACTIONS.FINISH' | translate }}</button>
|
||||
<button mat-raised-button color="primary" (click)="emitFilter()">{{ 'ACTIONS.APPLY' | translate }}</button>
|
||||
</div>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
@ -1,11 +1,11 @@
|
||||
<div class="footer-wrapper">
|
||||
<div class="footer-row">
|
||||
<div class="footer-links">
|
||||
<a target="_blank" *ngIf="policy?.tosLink" rel="noreferrer" [href]="policy?.tosLink" external>
|
||||
<div class="footer-links" *ngIf="authService.privacypolicy | async as pP">
|
||||
<a target="_blank" *ngIf="pP?.tosLink" rel="noreferrer" [href]="pP?.tosLink" external>
|
||||
<span>{{ 'FOOTER.LINKS.TOS' | translate }}</span>
|
||||
<i class="las la-external-link-alt"></i>
|
||||
</a>
|
||||
<a target="_blank" *ngIf="policy?.privacyLink" rel="noreferrer" [href]="policy?.privacyLink" external>
|
||||
<a target="_blank" *ngIf="pP?.privacyLink" rel="noreferrer" [href]="pP?.privacyLink" external>
|
||||
<span>{{ 'FOOTER.LINKS.PP' | translate }}</span>
|
||||
<i class="las la-external-link-alt"></i>
|
||||
</a>
|
||||
|
@ -8,16 +8,7 @@ import { faXTwitter } from '@fortawesome/free-brands-svg-icons';
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss'],
|
||||
})
|
||||
export class FooterComponent implements OnInit {
|
||||
public policy?: PrivacyPolicy.AsObject;
|
||||
export class FooterComponent {
|
||||
public faXTwitter = faXTwitter;
|
||||
constructor(public authService: GrpcAuthService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.getMyPrivacyPolicy().then((policyResp) => {
|
||||
if (policyResp.policy) {
|
||||
this.policy = policyResp.policy;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +53,10 @@
|
||||
overflow: hidden; // prevents multi-line errors from overlapping the control
|
||||
}
|
||||
|
||||
mat-dialog-container .cnsl-form-field-subscript-wrapper {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cnsl-form-field-hint-wrapper,
|
||||
.cnsl-form-field-error-wrapper {
|
||||
display: flex;
|
||||
|
@ -168,7 +168,11 @@
|
||||
|
||||
<span class="fill-space"></span>
|
||||
|
||||
<a class="doc-link" href="https://zitadel.com/docs" mat-stroked-button target="_blank">
|
||||
<a class="custom-link" *ngIf="customLink && customLinkText" href="{{ customLink }}" mat-stroked-button target="_blank">
|
||||
{{ customLinkText }}
|
||||
</a>
|
||||
|
||||
<a class="doc-link" *ngIf="docsLink" href="{{ docsLink }}" mat-stroked-button target="_blank">
|
||||
{{ 'MENU.DOCUMENTATION' | translate }}
|
||||
</a>
|
||||
|
||||
|
@ -224,7 +224,8 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
.doc-link,
|
||||
.custom-link {
|
||||
margin-right: 1rem;
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
@ -247,12 +248,16 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 600px) {
|
||||
display: inline;
|
||||
display: inline;
|
||||
|
||||
i {
|
||||
margin-right: -0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
i {
|
||||
margin-right: -0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 375px) {
|
||||
.iam-label {
|
||||
font-size: x-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
|
||||
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
@ -8,8 +8,8 @@ import { AuthenticationService } from 'src/app/services/authentication.service';
|
||||
import { BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ManagementService } from 'src/app/services/mgmt.service';
|
||||
|
||||
import { ActionKeysType } from '../action-keys/action-keys.component';
|
||||
import { GetPrivacyPolicyResponse } from 'src/app/proto/generated/zitadel/management_pb';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-header',
|
||||
@ -31,6 +31,9 @@ export class HeaderComponent implements OnDestroy {
|
||||
private destroy$: Subject<void> = new Subject();
|
||||
public BreadcrumbType: any = BreadcrumbType;
|
||||
public ActionKeysType: any = ActionKeysType;
|
||||
public docsLink = 'https://zitadel.com/docs';
|
||||
public customLink = '';
|
||||
public customLinkText = '';
|
||||
|
||||
public positions: ConnectedPosition[] = [
|
||||
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10),
|
||||
@ -47,7 +50,25 @@ export class HeaderComponent implements OnDestroy {
|
||||
public mgmtService: ManagementService,
|
||||
public breadcrumbService: BreadcrumbService,
|
||||
public router: Router,
|
||||
) {}
|
||||
) {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
public async loadData(): Promise<any> {
|
||||
const getData = (): Promise<GetPrivacyPolicyResponse.AsObject> => {
|
||||
return this.mgmtService.getPrivacyPolicy();
|
||||
};
|
||||
|
||||
getData()
|
||||
.then((resp) => {
|
||||
if (resp.policy) {
|
||||
this.docsLink = resp.policy.docsLink;
|
||||
this.customLink = resp.policy.customLink;
|
||||
this.customLinkText = resp.policy.customLinkText;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
|
@ -87,7 +87,7 @@
|
||||
</div>
|
||||
<div class="idp-table-provider-type" *ngSwitchCase="ProviderType.PROVIDER_TYPE_SAML">
|
||||
<img class="idp-logo" src="./assets/images/idp/saml-icon.svg" alt="saml" />
|
||||
SAML SP
|
||||
SAML
|
||||
</div>
|
||||
<div class="idp-table-provider-type" *ngSwitchDefault>coming soon</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Duration } from 'google-protobuf/google/protobuf/duration_pb';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
ListProvidersRequest as AdminListProvidersRequest,
|
||||
ListProvidersResponse as AdminListProvidersResponse,
|
||||
@ -36,6 +36,8 @@ import { ContextChangedWorkflowOverlays } from 'src/app/services/overlay/workflo
|
||||
import { PageEvent, PaginatorComponent } from '../paginator/paginator.component';
|
||||
import { PolicyComponentServiceType } from '../policies/policy-component-types.enum';
|
||||
import { WarnDialogComponent } from '../warn-dialog/warn-dialog.component';
|
||||
import { LoginPolicyService } from '../../services/login-policy.service';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-idp-table',
|
||||
@ -70,6 +72,7 @@ export class IdpTableComponent implements OnInit, OnDestroy {
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
private router: Router,
|
||||
private loginPolicySvc: LoginPolicyService,
|
||||
) {
|
||||
this.selection.changed.subscribe(() => {
|
||||
this.changedSelection.emit(this.selection.selected);
|
||||
@ -298,93 +301,15 @@ export class IdpTableComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private addLoginPolicy(): Promise<AddCustomLoginPolicyResponse.AsObject> {
|
||||
const mgmtreq = new AddCustomLoginPolicyRequest();
|
||||
mgmtreq.setAllowExternalIdp(this.loginPolicy.allowExternalIdp);
|
||||
mgmtreq.setAllowRegister(this.loginPolicy.allowRegister);
|
||||
mgmtreq.setAllowUsernamePassword(this.loginPolicy.allowUsernamePassword);
|
||||
mgmtreq.setForceMfa(this.loginPolicy.forceMfa);
|
||||
mgmtreq.setPasswordlessType(this.loginPolicy.passwordlessType);
|
||||
mgmtreq.setHidePasswordReset(this.loginPolicy.hidePasswordReset);
|
||||
mgmtreq.setMultiFactorsList(this.loginPolicy.multiFactorsList);
|
||||
mgmtreq.setSecondFactorsList(this.loginPolicy.secondFactorsList);
|
||||
|
||||
const pcl = new Duration()
|
||||
.setSeconds(this.loginPolicy.passwordCheckLifetime?.seconds ?? 0)
|
||||
.setNanos(this.loginPolicy.passwordCheckLifetime?.nanos ?? 0);
|
||||
mgmtreq.setPasswordCheckLifetime(pcl);
|
||||
|
||||
const elcl = new Duration()
|
||||
.setSeconds(this.loginPolicy.externalLoginCheckLifetime?.seconds ?? 0)
|
||||
.setNanos(this.loginPolicy.externalLoginCheckLifetime?.nanos ?? 0);
|
||||
mgmtreq.setExternalLoginCheckLifetime(elcl);
|
||||
|
||||
const misl = new Duration()
|
||||
.setSeconds(this.loginPolicy.mfaInitSkipLifetime?.seconds ?? 0)
|
||||
.setNanos(this.loginPolicy.mfaInitSkipLifetime?.nanos ?? 0);
|
||||
mgmtreq.setMfaInitSkipLifetime(misl);
|
||||
|
||||
const sfcl = new Duration()
|
||||
.setSeconds(this.loginPolicy.secondFactorCheckLifetime?.seconds ?? 0)
|
||||
.setNanos(this.loginPolicy.secondFactorCheckLifetime?.nanos ?? 0);
|
||||
mgmtreq.setSecondFactorCheckLifetime(sfcl);
|
||||
|
||||
const mficl = new Duration()
|
||||
.setSeconds(this.loginPolicy.multiFactorCheckLifetime?.seconds ?? 0)
|
||||
.setNanos(this.loginPolicy.multiFactorCheckLifetime?.nanos ?? 0);
|
||||
mgmtreq.setMultiFactorCheckLifetime(mficl);
|
||||
|
||||
mgmtreq.setAllowDomainDiscovery(this.loginPolicy.allowDomainDiscovery);
|
||||
mgmtreq.setIgnoreUnknownUsernames(this.loginPolicy.ignoreUnknownUsernames);
|
||||
mgmtreq.setDefaultRedirectUri(this.loginPolicy.defaultRedirectUri);
|
||||
|
||||
return (this.service as ManagementService).addCustomLoginPolicy(mgmtreq);
|
||||
}
|
||||
|
||||
public addIdp(idp: Provider.AsObject): Promise<any> {
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
if (this.isDefault) {
|
||||
return this.addLoginPolicy()
|
||||
.then(() => {
|
||||
this.loginPolicy.isDefault = false;
|
||||
return (this.service as ManagementService).addIDPToLoginPolicy(idp.id, idp.owner).then(() => {
|
||||
this.toast.showInfo('IDP.TOAST.ADDED', true);
|
||||
|
||||
setTimeout(() => {
|
||||
this.reloadIDPs$.next();
|
||||
}, 2000);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
});
|
||||
} else {
|
||||
return (this.service as ManagementService)
|
||||
.addIDPToLoginPolicy(idp.id, idp.owner)
|
||||
.then(() => {
|
||||
this.toast.showInfo('IDP.TOAST.ADDED', true);
|
||||
setTimeout(() => {
|
||||
this.reloadIDPs$.next();
|
||||
}, 2000);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
});
|
||||
}
|
||||
case PolicyComponentServiceType.ADMIN:
|
||||
return (this.service as AdminService)
|
||||
.addIDPToLoginPolicy(idp.id)
|
||||
.then(() => {
|
||||
this.toast.showInfo('IDP.TOAST.ADDED', true);
|
||||
setTimeout(() => {
|
||||
this.reloadIDPs$.next();
|
||||
}, 2000);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toast.showError(error);
|
||||
});
|
||||
}
|
||||
return firstValueFrom(this.loginPolicySvc.activateIdp(this.service, idp.id, idp.owner, this.loginPolicy))
|
||||
.then(() => {
|
||||
this.toast.showInfo('IDP.TOAST.ADDED', true);
|
||||
setTimeout(() => {
|
||||
this.reloadIDPs$.next();
|
||||
}, 2000);
|
||||
})
|
||||
.catch(this.toast.showError);
|
||||
}
|
||||
|
||||
public removeIdp(idp: Provider.AsObject): void {
|
||||
@ -403,7 +328,8 @@ export class IdpTableComponent implements OnInit, OnDestroy {
|
||||
switch (this.serviceType) {
|
||||
case PolicyComponentServiceType.MGMT:
|
||||
if (this.isDefault) {
|
||||
this.addLoginPolicy()
|
||||
this.loginPolicySvc
|
||||
.createCustomLoginPolicy(this.service as ManagementService, this.loginPolicy)
|
||||
.then(() => {
|
||||
this.loginPolicy.isDefault = false;
|
||||
return (this.service as ManagementService)
|
||||
|
@ -1,4 +1,4 @@
|
||||
<div class="container">
|
||||
<div class="keyboard-shortcuts">
|
||||
<h1 class="title" mat-dialog-title>{{ 'KEYBOARDSHORTCUTS.TITLE' | translate }}</h1>
|
||||
<div mat-dialog-content>
|
||||
<div *ngIf="isNotOnSystem" class="keyboard-shortcuts-group">
|
||||
|
@ -1,19 +1,28 @@
|
||||
@mixin keyboard-shortcuts-theme($theme) {
|
||||
$primary: map-get($theme, primary);
|
||||
$background: map-get($theme, background);
|
||||
$foreground: map-get($theme, foreground);
|
||||
$text-color: map-get($foreground, text);
|
||||
$accent: map-get($theme, accent);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
$accent-color: map-get($primary, 500);
|
||||
$back: map-get($background, background);
|
||||
$card-background-color: map-get($background, cards);
|
||||
.keyboard-shortcuts {
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px !important;
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
button {
|
||||
border-radius: 0.5rem;
|
||||
.mat-mdc-button-persistent-ripple {
|
||||
border-style: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fill-space {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-group {
|
||||
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
|
||||
|
||||
border: 1px solid $border-color;
|
||||
background-color: $card-background-color;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
@ -21,7 +30,6 @@
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-wrapper {
|
||||
@ -35,14 +43,6 @@
|
||||
|
||||
.keyboard-shortcut-name {
|
||||
font-size: 14px;
|
||||
|
||||
strong {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid map-get($foreground, dividers);
|
||||
}
|
||||
|
||||
.fill-space {
|
||||
@ -68,7 +68,6 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: if($is-dark-theme, #fff, #000);
|
||||
opacity: 0.15;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@ -86,30 +85,51 @@
|
||||
.keyboard-shortcuts-plus {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0;
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
button {
|
||||
border-radius: 0.5rem;
|
||||
.mat-mdc-button-persistent-ripple {
|
||||
border-style: none !important;
|
||||
@mixin keyboard-shortcuts-theme($theme) {
|
||||
$primary: map-get($theme, primary);
|
||||
$background: map-get($theme, background);
|
||||
$foreground: map-get($theme, foreground);
|
||||
$text-color: map-get($foreground, text);
|
||||
$accent: map-get($theme, accent);
|
||||
$is-dark-theme: map-get($theme, is-dark);
|
||||
$accent-color: map-get($primary, 500);
|
||||
$back: map-get($background, background);
|
||||
$card-background-color: map-get($background, cards);
|
||||
|
||||
.keyboard-shortcuts {
|
||||
.title {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-group {
|
||||
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
|
||||
border: 1px solid $border-color;
|
||||
background-color: $card-background-color;
|
||||
|
||||
h2 {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-wrapper {
|
||||
.keyboard-shortcut {
|
||||
.keyboard-shortcut-name {
|
||||
strong {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid map-get($foreground, dividers);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-action-key {
|
||||
.keyboard-shortcuts-key-overlay {
|
||||
background: if($is-dark-theme, #fff, #000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fill-space {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
@ -61,7 +61,7 @@
|
||||
<ng-container matColumnDef="expirationDate">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'USER.MACHINE.EXPIRATIONDATE' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let key">
|
||||
{{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM YYYY, HH:mm' }}
|
||||
{{ key.expirationDate | timestampToDate | localizedDate: 'EEE dd. MMM yyyy, HH:mm' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -96,7 +96,12 @@
|
||||
[routerLinkActive]="['active']"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
[routerLink]="['/org-settings']"
|
||||
*ngIf="(['policy.read'] | hasRole | async) && ((authService.cachedOrgs | async)?.length ?? 1) > 1"
|
||||
*ngIf="
|
||||
(['policy.read'] | hasRole | async) &&
|
||||
((['iam.read$', 'iam.write$'] | hasRole | async) === false ||
|
||||
(((authService.cachedOrgs | async)?.length ?? 1) > 1 &&
|
||||
(['iam.read$', 'iam.write$'] | hasRole | async)))
|
||||
"
|
||||
>
|
||||
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
|
||||
</a>
|
||||
|
@ -15,7 +15,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="org-wrapper">
|
||||
<div class="org-wrapper" cnslScrollable (scrollPosition)="onNearEndScroll($event)">
|
||||
<button
|
||||
class="org-button-with-pin"
|
||||
mat-button
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
|
||||
import { UntypedFormControl } from '@angular/forms';
|
||||
import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, tap } from 'rxjs';
|
||||
import { BehaviorSubject, catchError, debounceTime, finalize, from, map, Observable, of, pipe, scan, take, tap } from 'rxjs';
|
||||
import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb';
|
||||
import { Org, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { Org, OrgFieldName, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb';
|
||||
import { AuthenticationService } from 'src/app/services/authentication.service';
|
||||
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
|
||||
import { ToastService } from 'src/app/services/toast.service';
|
||||
|
||||
const ORG_QUERY_LIMIT = 100;
|
||||
|
||||
@Component({
|
||||
selector: 'cnsl-org-context',
|
||||
@ -16,7 +19,30 @@ export class OrgContextComponent implements OnInit {
|
||||
public pinned: SelectionModel<Org.AsObject> = new SelectionModel<Org.AsObject>(true, []);
|
||||
|
||||
public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false);
|
||||
public orgs$: Observable<Org.AsObject[]> = of([]);
|
||||
|
||||
public bottom: boolean = false;
|
||||
private _done: BehaviorSubject<any> = new BehaviorSubject(false);
|
||||
private _loading: BehaviorSubject<any> = new BehaviorSubject(false);
|
||||
public _orgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]);
|
||||
|
||||
public orgs$: Observable<Org.AsObject[]> = this._orgs.pipe(
|
||||
map((orgs) => {
|
||||
return orgs.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}),
|
||||
pipe(
|
||||
tap((orgs: Org.AsObject[]) => {
|
||||
this.pinned.clear();
|
||||
this.getPrefixedItem('pinned-orgs').then((stringifiedOrgs) => {
|
||||
if (stringifiedOrgs) {
|
||||
const orgIds: string[] = JSON.parse(stringifiedOrgs);
|
||||
const pinnedOrgs = orgs.filter((o) => orgIds.includes(o.id));
|
||||
pinnedOrgs.forEach((o) => this.pinned.select(o));
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public filterControl: UntypedFormControl = new UntypedFormControl('');
|
||||
@Input() public org!: Org.AsObject;
|
||||
@ViewChild('input', { static: false }) input!: ElementRef;
|
||||
@ -26,15 +52,17 @@ export class OrgContextComponent implements OnInit {
|
||||
constructor(
|
||||
public authService: AuthenticationService,
|
||||
private auth: GrpcAuthService,
|
||||
private toast: ToastService,
|
||||
) {
|
||||
this.filterControl.valueChanges.pipe(debounceTime(500)).subscribe((value) => {
|
||||
this.loadOrgs(value.trim().toLowerCase());
|
||||
const filteredValues = this.loadOrgs(0, value.trim().toLowerCase());
|
||||
this.mapAndUpdate(filteredValues, true);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.focusFilter();
|
||||
this.loadOrgs();
|
||||
this.init();
|
||||
}
|
||||
|
||||
public setActiveOrg(org: Org.AsObject) {
|
||||
@ -42,7 +70,24 @@ export class OrgContextComponent implements OnInit {
|
||||
this.closedCard.emit();
|
||||
}
|
||||
|
||||
public loadOrgs(filter?: string): void {
|
||||
public onNearEndScroll(position: 'top' | 'bottom'): void {
|
||||
if (position === 'bottom') {
|
||||
this.more();
|
||||
}
|
||||
}
|
||||
|
||||
public more(): void {
|
||||
const _cursor = this._orgs.getValue().length;
|
||||
let more: Promise<Org.AsObject[]> = this.loadOrgs(_cursor, '');
|
||||
this.mapAndUpdate(more);
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
let first: Promise<Org.AsObject[]> = this.loadOrgs(0);
|
||||
this.mapAndUpdate(first);
|
||||
}
|
||||
|
||||
public loadOrgs(offset: number, filter?: string): Promise<Org.AsObject[]> {
|
||||
if (!filter) {
|
||||
const value = this.input?.nativeElement?.value;
|
||||
if (value) {
|
||||
@ -61,29 +106,52 @@ export class OrgContextComponent implements OnInit {
|
||||
orgNameQuery.setMethod(TextQueryMethod.TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE);
|
||||
query.setNameQuery(orgNameQuery);
|
||||
}
|
||||
|
||||
this.orgLoading$.next(true);
|
||||
this.orgs$ = from(this.auth.listMyProjectOrgs(undefined, 0, query ? [query] : undefined)).pipe(
|
||||
map((resp) => {
|
||||
return resp.resultList.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}),
|
||||
catchError(() => of([])),
|
||||
pipe(
|
||||
tap((orgs: Org.AsObject[]) => {
|
||||
this.pinned.clear();
|
||||
this.getPrefixedItem('pinned-orgs').then((stringifiedOrgs) => {
|
||||
if (stringifiedOrgs) {
|
||||
const orgIds: string[] = JSON.parse(stringifiedOrgs);
|
||||
const pinnedOrgs = orgs.filter((o) => orgIds.includes(o.id));
|
||||
pinnedOrgs.forEach((o) => this.pinned.select(o));
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
finalize(() => {
|
||||
return this.auth
|
||||
.listMyProjectOrgs(ORG_QUERY_LIMIT, offset, query ? [query] : undefined, OrgFieldName.ORG_FIELD_NAME_NAME, 'asc')
|
||||
.then((result) => {
|
||||
this.orgLoading$.next(false);
|
||||
}),
|
||||
);
|
||||
return result.resultList;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.orgLoading$.next(false);
|
||||
this.toast.showError(error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
private mapAndUpdate(col: Promise<Org.AsObject[]>, clear?: boolean): any {
|
||||
if (clear === false && (this._done.value || this._loading.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.bottom) {
|
||||
this._loading.next(true);
|
||||
|
||||
return from(col)
|
||||
.pipe(
|
||||
take(1),
|
||||
tap((res: Org.AsObject[]) => {
|
||||
const current = this._orgs.getValue();
|
||||
if (clear) {
|
||||
this._orgs.next(res);
|
||||
} else {
|
||||
this._orgs.next([...current, ...res]);
|
||||
}
|
||||
|
||||
this._loading.next(false);
|
||||
if (!res.length) {
|
||||
this._done.next(true);
|
||||
}
|
||||
}),
|
||||
catchError((_) => {
|
||||
this._loading.next(false);
|
||||
this.bottom = true;
|
||||
return of([]);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
public closeCard(element: HTMLElement): void {
|
||||
|
@ -12,11 +12,13 @@ import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
|
||||
|
||||
import { InputModule } from '../input/input.module';
|
||||
import { OrgContextComponent } from './org-context.component';
|
||||
import { ScrollableDirective } from 'src/app/directives/scrollable/scrollable.directive';
|
||||
|
||||
@NgModule({
|
||||
declarations: [OrgContextComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ScrollableDirective,
|
||||
FormsModule,
|
||||
A11yModule,
|
||||
ReactiveFormsModule,
|
||||
|
@ -52,7 +52,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||
{{ 'ORG.PAGES.NAME' | translate }}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let org" (click)="setAndNavigateToOrg(org)">
|
||||
|
@ -4,7 +4,7 @@
|
||||
<span>{{ length }} </span>{{ 'PAGINATOR.COUNT' | translate }}
|
||||
</p>
|
||||
<p class="ts cnsl-secondary-text" *ngIf="timestamp" data-e2e="timestamp">
|
||||
{{ timestamp | timestampToDate | localizedDate: 'EEEE dd. MMM YYYY, HH:mm' }}
|
||||
{{ timestamp | timestampToDate | localizedDate: 'EEEE dd. MMM yyyy, HH:mm' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="fill-space"></span>
|
||||
|
@ -50,4 +50,12 @@
|
||||
<i *ngIf="password?.dirty && !password?.errors?.['errorslowercasemissing']" class="las la-check green"></i>
|
||||
<span class="cnsl-secondary-text">{{ 'ERRORS.LOWERCASEMISSING' | translate }}</span>
|
||||
</div>
|
||||
<div class="val">
|
||||
<i *ngIf="password?.value?.length === 0 || password?.value?.length <= 70" class="las la-check green"></i>
|
||||
<i *ngIf="password?.value?.length > 70" class="las la-times red"></i>
|
||||
|
||||
<span class="cnsl-secondary-text"
|
||||
>{{ 'USER.PASSWORD.MAXLENGTHERROR' | translate: { value: 70 } }} ({{ password?.value?.length }}/{{ 70 }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user