diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b5dc7b5c5..8dbe8ebc5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2023.10.2 +current_version = 2023.10.6 tag = True commit = True parse = (?P\d+)\.(?P\d+)\.(?P\d+) diff --git a/.dockerignore b/.dockerignore index 352faf761..8d20d66d6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,4 @@ blueprints/local .git !gen-ts-api/node_modules !gen-ts-api/dist/** +!gen-go-api/ diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index c13282ba0..b2f2865ef 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -2,36 +2,39 @@ name: "Setup authentik testing environment" description: "Setup authentik testing environment" inputs: - postgresql_tag: + postgresql_version: description: "Optional postgresql image tag" - default: "12" + default: "16" runs: using: "composite" steps: - - name: Install poetry + - name: Install poetry & deps shell: bash run: | pipx install poetry || true - sudo apt update - sudo apt install -y libpq-dev openssl libxmlsec1-dev pkg-config gettext + sudo apt-get update + sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext - name: Setup python and restore poetry - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version-file: "pyproject.toml" cache: "poetry" - name: Setup node uses: actions/setup-node@v3 with: - node-version: "20" + node-version-file: web/package.json cache: "npm" cache-dependency-path: web/package-lock.json + - name: Setup go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" - name: Setup dependencies shell: bash run: | - export PSQL_TAG=${{ inputs.postgresql_tag }} + export PSQL_TAG=${{ inputs.postgresql_version }} docker-compose -f .github/actions/setup/docker-compose.yml up -d - poetry env use python3.11 poetry install cd web && npm ci - name: Generate config diff --git a/.github/actions/setup/docker-compose.yml b/.github/actions/setup/docker-compose.yml index 5d7a1053d..d012dd21f 100644 --- a/.github/actions/setup/docker-compose.yml +++ b/.github/actions/setup/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.7" services: postgresql: - image: docker.io/library/postgres:${PSQL_TAG:-12} + image: docker.io/library/postgres:${PSQL_TAG:-16} volumes: - db-data:/var/lib/postgresql/data environment: diff --git a/.github/codespell-words.txt b/.github/codespell-words.txt index 71f2f1c2c..29fb24832 100644 --- a/.github/codespell-words.txt +++ b/.github/codespell-words.txt @@ -2,3 +2,4 @@ keypair keypairs hass warmup +ontext diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5aacf8ef6..400300236 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,6 +35,7 @@ updates: sentry: patterns: - "@sentry/*" + - "@spotlightjs/*" babel: patterns: - "@babel/*" @@ -66,6 +67,7 @@ updates: sentry: patterns: - "@sentry/*" + - "@spotlightjs/*" babel: patterns: - "@babel/*" diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 311b8324c..71bfc0d7a 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -48,25 +48,34 @@ jobs: - name: run migrations run: poetry run python -m lifecycle.migrate test-migrations-from-stable: + name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} runs-on: ubuntu-latest - continue-on-error: true + strategy: + fail-fast: false + matrix: + psql: + - 12-alpine + - 15-alpine + - 16-alpine steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup authentik env - uses: ./.github/actions/setup - name: checkout stable run: | + # Delete all poetry envs + rm -rf /home/runner/.cache/pypoetry # Copy current, latest config to local cp authentik/lib/default.yml local.env.yml cp -R .github .. cp -R scripts .. - git checkout $(git describe --tags $(git rev-list --tags --max-count=1)) + git checkout version/$(python -c "from authentik import __version__; print(__version__)") rm -rf .github/ scripts/ mv ../.github ../scripts . - - name: Setup authentik env (ensure stable deps are installed) + - name: Setup authentik env (stable) uses: ./.github/actions/setup + with: + postgresql_version: ${{ matrix.psql }} - name: run migrations to stable run: poetry run python -m lifecycle.migrate - name: checkout current code @@ -76,11 +85,21 @@ jobs: git reset --hard HEAD git clean -d -fx . git checkout $GITHUB_SHA - poetry install + # Delete previous poetry env + rm -rf /home/runner/.cache/pypoetry/virtualenvs/* - name: Setup authentik env (ensure latest deps are installed) uses: ./.github/actions/setup + with: + postgresql_version: ${{ matrix.psql }} - name: migrate to latest - run: poetry run python -m lifecycle.migrate + run: | + poetry run python -m lifecycle.migrate + - name: run tests + env: + # Test in the main database that we just migrated from the previous stable version + AUTHENTIK_POSTGRESQL__TEST__NAME: authentik + run: | + poetry run make test test-unittest: name: test-unittest - PostgreSQL ${{ matrix.psql }} runs-on: ubuntu-latest @@ -97,7 +116,7 @@ jobs: - name: Setup authentik env uses: ./.github/actions/setup with: - postgresql_tag: ${{ matrix.psql }} + postgresql_version: ${{ matrix.psql }} - name: run unittest run: | poetry run make test @@ -187,6 +206,7 @@ jobs: needs: ci-core-mark runs-on: ubuntu-latest permissions: + # Needed to upload contianer images to ghcr.io packages: write timeout-minutes: 120 steps: @@ -229,16 +249,11 @@ jobs: VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} cache-from: type=gha cache-to: type=gha,mode=max - - name: Comment on PR - if: github.event_name == 'pull_request' - continue-on-error: true - uses: ./.github/actions/comment-pr-instructions - with: - tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} build-arm64: needs: ci-core-mark runs-on: ubuntu-latest permissions: + # Needed to upload contianer images to ghcr.io packages: write timeout-minutes: 120 steps: @@ -282,3 +297,26 @@ jobs: platforms: linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + pr-comment: + needs: + - build + - build-arm64 + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} + permissions: + # Needed to write comments on PRs + pull-requests: write + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: prepare variables + uses: ./.github/actions/docker-push-variables + id: ev + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + - name: Comment on PR + uses: ./.github/actions/comment-pr-instructions + with: + tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }} diff --git a/.github/workflows/ci-outpost.yml b/.github/workflows/ci-outpost.yml index 4b286d07f..35c83ac86 100644 --- a/.github/workflows/ci-outpost.yml +++ b/.github/workflows/ci-outpost.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" - name: Prepare and generate API @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" - name: Setup authentik env @@ -65,8 +65,10 @@ jobs: - proxy - ldap - radius + - rac runs-on: ubuntu-latest permissions: + # Needed to upload contianer images to ghcr.io packages: write steps: - uses: actions/checkout@v4 @@ -118,18 +120,19 @@ jobs: - proxy - ldap - radius + - rac goos: [linux] goarch: [amd64, arm64] steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: web/package.json cache: "npm" cache-dependency-path: web/package-lock.json - name: Generate API diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index fd1e36182..43ca0a168 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: ${{ matrix.project }}/package.json cache: "npm" cache-dependency-path: ${{ matrix.project }}/package-lock.json - working-directory: ${{ matrix.project }}/ @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: web/package.json cache: "npm" cache-dependency-path: web/package-lock.json - working-directory: web/ @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: ${{ matrix.project }}/package.json cache: "npm" cache-dependency-path: ${{ matrix.project }}/package-lock.json - working-directory: ${{ matrix.project }}/ @@ -78,7 +78,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: web/package.json cache: "npm" cache-dependency-path: web/package-lock.json - working-directory: web/ @@ -110,7 +110,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: web/package.json cache: "npm" cache-dependency-path: web/package-lock.json - working-directory: web/ diff --git a/.github/workflows/ci-website.yml b/.github/workflows/ci-website.yml index 2a52c7c2e..78a3a8f8a 100644 --- a/.github/workflows/ci-website.yml +++ b/.github/workflows/ci-website.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: website/package.json cache: "npm" cache-dependency-path: website/package-lock.json - working-directory: website/ @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: website/package.json cache: "npm" cache-dependency-path: website/package-lock.json - working-directory: website/ @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: website/package.json cache: "npm" cache-dependency-path: website/package-lock.json - working-directory: website/ diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5f1255f56..c8c0cc11f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,10 +27,10 @@ jobs: - name: Setup authentik env uses: ./.github/actions/setup - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/gha-cache-cleanup.yml b/.github/workflows/gha-cache-cleanup.yml index 178d00cac..473625d1c 100644 --- a/.github/workflows/gha-cache-cleanup.yml +++ b/.github/workflows/gha-cache-cleanup.yml @@ -6,6 +6,10 @@ on: types: - closed +permissions: + # Permission to delete cache + actions: write + jobs: cleanup: runs-on: ubuntu-latest diff --git a/.github/workflows/release-next-branch.yml b/.github/workflows/release-next-branch.yml index 233398e95..57b672d28 100644 --- a/.github/workflows/release-next-branch.yml +++ b/.github/workflows/release-next-branch.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: permissions: + # Needed to be able to push to the next branch contents: write jobs: diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 9ba260281..c002ab8a5 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -8,6 +8,7 @@ jobs: build-server: runs-on: ubuntu-latest permissions: + # Needed to upload contianer images to ghcr.io packages: write steps: - uses: actions/checkout@v4 @@ -55,6 +56,7 @@ jobs: build-outpost: runs-on: ubuntu-latest permissions: + # Needed to upload contianer images to ghcr.io packages: write strategy: fail-fast: false @@ -63,9 +65,10 @@ jobs: - proxy - ldap - radius + - rac steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" - name: Set up QEMU @@ -110,6 +113,9 @@ jobs: build-outpost-binary: timeout-minutes: 120 runs-on: ubuntu-latest + permissions: + # Needed to upload binaries to the release + contents: write strategy: fail-fast: false matrix: @@ -121,12 +127,12 @@ jobs: goarch: [amd64, arm64] steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version-file: "go.mod" - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: web/package.json cache: "npm" cache-dependency-path: web/package-lock.json - name: Build web diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 2365145b4..a0b896a92 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -30,7 +30,7 @@ jobs: private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Extract version number id: get_version - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ steps.generate_token.outputs.token }} script: | diff --git a/.github/workflows/repo-stale.yml b/.github/workflows/repo-stale.yml index d7b9b66b4..24f0ac0fb 100644 --- a/.github/workflows/repo-stale.yml +++ b/.github/workflows/repo-stale.yml @@ -6,8 +6,8 @@ on: workflow_dispatch: permissions: + # Needed to update issues and PRs issues: write - pull-requests: write jobs: stale: @@ -18,7 +18,7 @@ jobs: with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: repo-token: ${{ steps.generate_token.outputs.token }} days-before-stale: 60 diff --git a/.github/workflows/translation-advice.yml b/.github/workflows/translation-advice.yml index f7a788fb6..ad76424fa 100644 --- a/.github/workflows/translation-advice.yml +++ b/.github/workflows/translation-advice.yml @@ -7,7 +7,12 @@ on: paths: - "!**" - "locale/**" - - "web/src/locales/**" + - "!locale/en/**" + - "web/xliff/**" + +permissions: + # Permission to write comment + pull-requests: write jobs: post-comment: diff --git a/.github/workflows/translation-rename.yml b/.github/workflows/translation-rename.yml index b2c947bda..7fe0a7ab5 100644 --- a/.github/workflows/translation-rename.yml +++ b/.github/workflows/translation-rename.yml @@ -6,6 +6,10 @@ on: pull_request: types: [opened, reopened] +permissions: + # Permission to rename PR + pull-requests: write + jobs: rename_pr: runs-on: ubuntu-latest diff --git a/.github/workflows/web-api-publish.yml b/.github/workflows/web-api-publish.yml index a6dfaeaa9..4c617a199 100644 --- a/.github/workflows/web-api-publish.yml +++ b/.github/workflows/web-api-publish.yml @@ -19,7 +19,7 @@ jobs: token: ${{ steps.generate_token.outputs.token }} - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: web/package.json registry-url: "https://registry.npmjs.org" - name: Generate API Client run: make gen-client-ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 625e45489..dd280d682 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -14,6 +14,7 @@ "ms-python.pylint", "ms-python.python", "ms-python.vscode-pylance", + "ms-python.black-formatter", "redhat.vscode-yaml", "Tobermory.es6-string-html", "unifiedjs.vscode-mdx", diff --git a/.vscode/settings.json b/.vscode/settings.json index e674c02b5..218800d1e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,10 +19,8 @@ "slo", "scim", ], - "python.linting.pylintEnabled": true, "todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showBadges": true, - "python.formatting.provider": "black", "yaml.customTags": [ "!Find sequence", "!KeyOf scalar", diff --git a/Dockerfile b/Dockerfile index 8fc19d534..780e9ce3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1 + # Stage 1: Build website FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder @@ -7,7 +9,7 @@ WORKDIR /work/website RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \ --mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \ - --mount=type=cache,target=/root/.npm \ + --mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \ npm ci --include=dev COPY ./website /work/website/ @@ -25,7 +27,7 @@ WORKDIR /work/web RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ - --mount=type=cache,target=/root/.npm \ + --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ npm ci --include=dev COPY ./web /work/web/ @@ -35,7 +37,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api RUN npm run build # Stage 3: Build go proxy -FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.3-bookworm AS go-builder +FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.6-bookworm AS go-builder ARG TARGETOS ARG TARGETARCH @@ -62,14 +64,14 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum ENV CGO_ENABLED=0 -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ +RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ + --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server # Stage 4: MaxMind GeoIP -FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as geoip +FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.1 as geoip -ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City" +ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" ENV GEOIPUPDATE_VERBOSE="true" ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID" ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY" @@ -81,7 +83,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" # Stage 5: Python dependencies -FROM docker.io/python:3.11.5-bookworm AS python-deps +FROM docker.io/python:3.12.1-slim-bookworm AS python-deps WORKDIR /ak-root/poetry @@ -89,7 +91,9 @@ ENV VENV_PATH="/ak-root/venv" \ POETRY_VIRTUALENVS_CREATE=false \ PATH="/ak-root/venv/bin:$PATH" -RUN --mount=type=cache,target=/var/cache/apt \ +RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache + +RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \ apt-get update && \ # Required for installing pip packages apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev @@ -104,7 +108,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \ poetry install --only=main --no-ansi --no-interaction # Stage 6: Run -FROM docker.io/python:3.11.5-slim-bookworm AS final-image +FROM docker.io/python:3.12.1-slim-bookworm AS final-image ARG GIT_BUILD_HASH ARG VERSION @@ -121,7 +125,7 @@ WORKDIR / # We cannot cache this layer otherwise we'll end up with a bigger image RUN apt-get update && \ # Required for runtime - apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 && \ + apt-get install -y --no-install-recommends libpq5 openssl libxmlsec1-openssl libmaxminddb0 ca-certificates && \ # Required for bootstrap & healtcheck apt-get install -y --no-install-recommends runit && \ apt-get clean && \ diff --git a/Makefile b/Makefile index 07a84fb70..93092a779 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ test: ## Run the server tests and produce a coverage report (locally) lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors. isort $(PY_SOURCES) black $(PY_SOURCES) - ruff $(PY_SOURCES) + ruff --fix $(PY_SOURCES) codespell -w $(CODESPELL_ARGS) lint: ## Lint the python and golang sources @@ -110,11 +110,14 @@ gen-diff: ## (Release) generate the changelog diff between the current schema a --markdown /local/diff.md \ /local/old_schema.yml /local/schema.yml rm old_schema.yml + sed -i 's/{/{/g' diff.md + sed -i 's/}/}/g' diff.md npx prettier --write diff.md gen-clean: - rm -rf web/api/src/ - rm -rf api/ + rm -rf gen-go-api/ + rm -rf gen-ts-api/ + rm -rf web/node_modules/@goauthentik/api/ gen-client-ts: ## Build and install the authentik API for Typescript into the authentik UI Application docker run \ diff --git a/SECURITY.md b/SECURITY.md index 0d9d6a673..9bb674f23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version. +## Independent audits and pentests + +In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53). + ## What authentik classifies as a CVE CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is: diff --git a/authentik/__init__.py b/authentik/__init__.py index 4321371f7..352d7580f 100644 --- a/authentik/__init__.py +++ b/authentik/__init__.py @@ -2,7 +2,7 @@ from os import environ from typing import Optional -__version__ = "2023.10.2" +__version__ = "2023.10.6" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" diff --git a/authentik/admin/api/system.py b/authentik/admin/api/system.py index 7e7d2d920..5a7007153 100644 --- a/authentik/admin/api/system.py +++ b/authentik/admin/api/system.py @@ -30,7 +30,7 @@ class RuntimeDict(TypedDict): uname: str -class SystemSerializer(PassiveSerializer): +class SystemInfoSerializer(PassiveSerializer): """Get system information.""" http_headers = SerializerMethodField() @@ -91,14 +91,14 @@ class SystemView(APIView): permission_classes = [HasPermission("authentik_rbac.view_system_info")] pagination_class = None filter_backends = [] - serializer_class = SystemSerializer + serializer_class = SystemInfoSerializer - @extend_schema(responses={200: SystemSerializer(many=False)}) + @extend_schema(responses={200: SystemInfoSerializer(many=False)}) def get(self, request: Request) -> Response: """Get system information.""" - return Response(SystemSerializer(request).data) + return Response(SystemInfoSerializer(request).data) - @extend_schema(responses={200: SystemSerializer(many=False)}) + @extend_schema(responses={200: SystemInfoSerializer(many=False)}) def post(self, request: Request) -> Response: """Get system information.""" - return Response(SystemSerializer(request).data) + return Response(SystemInfoSerializer(request).data) diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index cd23a1835..c09bca5a3 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -12,6 +12,8 @@ from authentik.blueprints.tests import reconcile_app from authentik.core.models import Token, TokenIntents, User, UserTypes from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.lib.generators import generate_id +from authentik.outposts.apps import MANAGED_OUTPOST +from authentik.outposts.models import Outpost from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API from authentik.providers.oauth2.models import AccessToken, OAuth2Provider @@ -49,8 +51,12 @@ class TestAPIAuth(TestCase): with self.assertRaises(AuthenticationFailed): bearer_auth(f"Bearer {token.key}".encode()) - def test_managed_outpost(self): + @reconcile_app("authentik_outposts") + def test_managed_outpost_fail(self): """Test managed outpost""" + outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first() + outpost.user.delete() + outpost.delete() with self.assertRaises(AuthenticationFailed): bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) diff --git a/authentik/api/v3/config.py b/authentik/api/v3/config.py index bbc676647..93b783629 100644 --- a/authentik/api/v3/config.py +++ b/authentik/api/v3/config.py @@ -19,7 +19,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from authentik.core.api.utils import PassiveSerializer -from authentik.events.geo import GEOIP_READER +from authentik.events.context_processors.base import get_context_processors from authentik.lib.config import CONFIG capabilities = Signal() @@ -30,6 +30,7 @@ class Capabilities(models.TextChoices): CAN_SAVE_MEDIA = "can_save_media" CAN_GEO_IP = "can_geo_ip" + CAN_ASN = "can_asn" CAN_IMPERSONATE = "can_impersonate" CAN_DEBUG = "can_debug" IS_ENTERPRISE = "is_enterprise" @@ -68,8 +69,9 @@ class ConfigView(APIView): deb_test = settings.DEBUG or settings.TEST if Path(settings.MEDIA_ROOT).is_mount() or deb_test: caps.append(Capabilities.CAN_SAVE_MEDIA) - if GEOIP_READER.enabled: - caps.append(Capabilities.CAN_GEO_IP) + for processor in get_context_processors(): + if cap := processor.capability(): + caps.append(cap) if CONFIG.get_bool("impersonation"): caps.append(Capabilities.CAN_IMPERSONATE) if settings.DEBUG: # pragma: no cover @@ -93,10 +95,10 @@ class ConfigView(APIView): "traces_sample_rate": float(CONFIG.get("error_reporting.sample_rate", 0.4)), }, "capabilities": self.get_capabilities(), - "cache_timeout": CONFIG.get_int("redis.cache_timeout"), - "cache_timeout_flows": CONFIG.get_int("redis.cache_timeout_flows"), - "cache_timeout_policies": CONFIG.get_int("redis.cache_timeout_policies"), - "cache_timeout_reputation": CONFIG.get_int("redis.cache_timeout_reputation"), + "cache_timeout": CONFIG.get_int("cache.timeout"), + "cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"), + "cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"), + "cache_timeout_reputation": CONFIG.get_int("cache.timeout_reputation"), } ) diff --git a/authentik/api/v3/urls.py b/authentik/api/v3/urls.py index f22a80536..a7e610efa 100644 --- a/authentik/api/v3/urls.py +++ b/authentik/api/v3/urls.py @@ -21,7 +21,9 @@ _other_urls = [] for _authentik_app in get_apps(): try: api_urls = import_module(f"{_authentik_app.name}.urls") - except (ModuleNotFoundError, ImportError) as exc: + except ModuleNotFoundError: + continue + except ImportError as exc: LOGGER.warning("Could not import app's URLs", app_name=_authentik_app.name, exc=exc) continue if not hasattr(api_urls, "api_urlpatterns"): diff --git a/authentik/blueprints/api.py b/authentik/blueprints/api.py index 721eb5dcb..7abf488da 100644 --- a/authentik/blueprints/api.py +++ b/authentik/blueprints/api.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework.decorators import action from rest_framework.exceptions import ValidationError -from rest_framework.fields import CharField, DateTimeField, JSONField +from rest_framework.fields import CharField, DateTimeField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer @@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer from authentik.blueprints.v1.oci import OCI_PREFIX from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import PassiveSerializer +from authentik.core.api.utils import JSONDictField, PassiveSerializer class ManagedSerializer: @@ -28,7 +28,7 @@ class MetadataSerializer(PassiveSerializer): """Serializer for blueprint metadata""" name = CharField() - labels = JSONField() + labels = JSONDictField() class BlueprintInstanceSerializer(ModelSerializer): diff --git a/authentik/blueprints/apps.py b/authentik/blueprints/apps.py index 90df91c00..aba14d552 100644 --- a/authentik/blueprints/apps.py +++ b/authentik/blueprints/apps.py @@ -40,7 +40,7 @@ class ManagedAppConfig(AppConfig): meth() self._logger.debug("Successfully reconciled", name=name) except (DatabaseError, ProgrammingError, InternalError) as exc: - self._logger.debug("Failed to run reconcile", name=name, exc=exc) + self._logger.warning("Failed to run reconcile", name=name, exc=exc) class AuthentikBlueprintsConfig(ManagedAppConfig): diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index eb4942958..13ee74063 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -35,7 +35,7 @@ from authentik.core.models import ( Source, UserSourceConnection, ) -from authentik.enterprise.models import LicenseUsage +from authentik.enterprise.models import LicenseKey, LicenseUsage from authentik.events.utils import cleanse_dict from authentik.flows.models import FlowToken, Stage from authentik.lib.models import SerializerModel @@ -108,12 +108,16 @@ class Importer: self.__pk_map: dict[Any, Model] = {} self._import = blueprint self.logger = get_logger() - ctx = {} + ctx = self.default_context() always_merger.merge(ctx, self._import.context) if context: always_merger.merge(ctx, context) self._import.context = ctx + def default_context(self): + """Default context""" + return {"goauthentik.io/enterprise/licensed": LicenseKey.get_total().is_valid()} + @staticmethod def from_string(yaml_input: str, context: dict | None = None) -> "Importer": """Parse YAML string and create blueprint importer from it""" diff --git a/authentik/blueprints/v1/meta/apply_blueprint.py b/authentik/blueprints/v1/meta/apply_blueprint.py index 5946342a3..0a8d84e66 100644 --- a/authentik/blueprints/v1/meta/apply_blueprint.py +++ b/authentik/blueprints/v1/meta/apply_blueprint.py @@ -2,11 +2,11 @@ from typing import TYPE_CHECKING from rest_framework.exceptions import ValidationError -from rest_framework.fields import BooleanField, JSONField +from rest_framework.fields import BooleanField from structlog.stdlib import get_logger from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry -from authentik.core.api.utils import PassiveSerializer, is_dict +from authentik.core.api.utils import JSONDictField, PassiveSerializer if TYPE_CHECKING: from authentik.blueprints.models import BlueprintInstance @@ -17,7 +17,7 @@ LOGGER = get_logger() class ApplyBlueprintMetaSerializer(PassiveSerializer): """Serializer for meta apply blueprint model""" - identifiers = JSONField(validators=[is_dict]) + identifiers = JSONDictField() required = BooleanField(default=True) # We cannot override `instance` as that will confuse rest_framework diff --git a/authentik/blueprints/v1/tasks.py b/authentik/blueprints/v1/tasks.py index 8ff86c996..686e4747c 100644 --- a/authentik/blueprints/v1/tasks.py +++ b/authentik/blueprints/v1/tasks.py @@ -75,13 +75,13 @@ class BlueprintEventHandler(FileSystemEventHandler): return if event.is_directory: return + root = Path(CONFIG.get("blueprints_dir")).absolute() + path = Path(event.src_path).absolute() + rel_path = str(path.relative_to(root)) if isinstance(event, FileCreatedEvent): - LOGGER.debug("new blueprint file created, starting discovery") - blueprints_discovery.delay() + LOGGER.debug("new blueprint file created, starting discovery", path=rel_path) + blueprints_discovery.delay(rel_path) if isinstance(event, FileModifiedEvent): - path = Path(event.src_path) - root = Path(CONFIG.get("blueprints_dir")).absolute() - rel_path = str(path.relative_to(root)) for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True): LOGGER.debug("modified blueprint file, starting apply", instance=instance) apply_blueprint.delay(instance.pk.hex) @@ -98,39 +98,32 @@ def blueprints_find_dict(): return blueprints -def blueprints_find(): +def blueprints_find() -> list[BlueprintFile]: """Find blueprints and return valid ones""" blueprints = [] root = Path(CONFIG.get("blueprints_dir")) for path in root.rglob("**/*.yaml"): + rel_path = path.relative_to(root) # Check if any part in the path starts with a dot and assume a hidden file if any(part for part in path.parts if part.startswith(".")): continue - LOGGER.debug("found blueprint", path=str(path)) with open(path, "r", encoding="utf-8") as blueprint_file: try: raw_blueprint = load(blueprint_file.read(), BlueprintLoader) except YAMLError as exc: raw_blueprint = None - LOGGER.warning("failed to parse blueprint", exc=exc, path=str(path)) + LOGGER.warning("failed to parse blueprint", exc=exc, path=str(rel_path)) if not raw_blueprint: continue metadata = raw_blueprint.get("metadata", None) version = raw_blueprint.get("version", 1) if version != 1: - LOGGER.warning("invalid blueprint version", version=version, path=str(path)) + LOGGER.warning("invalid blueprint version", version=version, path=str(rel_path)) continue file_hash = sha512(path.read_bytes()).hexdigest() - blueprint = BlueprintFile( - str(path.relative_to(root)), version, file_hash, int(path.stat().st_mtime) - ) + blueprint = BlueprintFile(str(rel_path), version, file_hash, int(path.stat().st_mtime)) blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None blueprints.append(blueprint) - LOGGER.debug( - "parsed & loaded blueprint", - hash=file_hash, - path=str(path), - ) return blueprints @@ -138,10 +131,12 @@ def blueprints_find(): throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True ) @prefill_task -def blueprints_discovery(self: MonitoredTask): +def blueprints_discovery(self: MonitoredTask, path: Optional[str] = None): """Find blueprints and check if they need to be created in the database""" count = 0 for blueprint in blueprints_find(): + if path and blueprint.path != path: + continue check_blueprint_v1_file(blueprint) count += 1 self.set_status( @@ -171,7 +166,11 @@ def check_blueprint_v1_file(blueprint: BlueprintFile): metadata={}, ) instance.save() + LOGGER.info( + "Creating new blueprint instance from file", instance=instance, path=instance.path + ) if instance.last_applied_hash != blueprint.hash: + LOGGER.info("Applying blueprint due to changed file", instance=instance, path=instance.path) apply_blueprint.delay(str(instance.pk)) diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 03c1aeaf3..2d77937be 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -14,7 +14,8 @@ from ua_parser import user_agent_parser from authentik.api.authorization import OwnerSuperuserPermissions from authentik.core.api.used_by import UsedByMixin from authentik.core.models import AuthenticatedSession -from authentik.events.geo import GEOIP_READER, GeoIPDict +from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict +from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict class UserAgentDeviceDict(TypedDict): @@ -59,6 +60,7 @@ class AuthenticatedSessionSerializer(ModelSerializer): current = SerializerMethodField() user_agent = SerializerMethodField() geo_ip = SerializerMethodField() + asn = SerializerMethodField() def get_current(self, instance: AuthenticatedSession) -> bool: """Check if session is currently active session""" @@ -70,8 +72,12 @@ class AuthenticatedSessionSerializer(ModelSerializer): return user_agent_parser.Parse(instance.last_user_agent) def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover - """Get parsed user agent""" - return GEOIP_READER.city_dict(instance.last_ip) + """Get GeoIP Data""" + return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip) + + def get_asn(self, instance: AuthenticatedSession) -> Optional[ASNDict]: # pragma: no cover + """Get ASN Data""" + return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip) class Meta: model = AuthenticatedSession @@ -80,6 +86,7 @@ class AuthenticatedSessionSerializer(ModelSerializer): "current", "user_agent", "geo_ip", + "asn", "user", "last_ip", "last_user_agent", diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py index 21ba19974..04670844d 100644 --- a/authentik/core/api/groups.py +++ b/authentik/core/api/groups.py @@ -8,7 +8,7 @@ from django_filters.filterset import FilterSet from drf_spectacular.utils import OpenApiResponse, extend_schema from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action -from rest_framework.fields import CharField, IntegerField, JSONField +from rest_framework.fields import CharField, IntegerField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError @@ -16,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet from authentik.api.decorators import permission_required from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import PassiveSerializer, is_dict +from authentik.core.api.utils import JSONDictField, PassiveSerializer from authentik.core.models import Group, User from authentik.rbac.api.roles import RoleSerializer @@ -24,7 +24,7 @@ from authentik.rbac.api.roles import RoleSerializer class GroupMemberSerializer(ModelSerializer): """Stripped down user serializer to show relevant users for groups""" - attributes = JSONField(validators=[is_dict], required=False) + attributes = JSONDictField(required=False) uid = CharField(read_only=True) class Meta: @@ -44,7 +44,7 @@ class GroupMemberSerializer(ModelSerializer): class GroupSerializer(ModelSerializer): """Group Serializer""" - attributes = JSONField(validators=[is_dict], required=False) + attributes = JSONDictField(required=False) users_obj = ListSerializer( child=GroupMemberSerializer(), read_only=True, source="users", required=False ) diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 1e7436be9..d0fa7267b 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -19,6 +19,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.models import PropertyMapping +from authentik.enterprise.apps import EnterpriseConfig from authentik.events.utils import sanitize_item from authentik.lib.utils.reflection import all_subclasses from authentik.policies.api.exec import PolicyTestSerializer @@ -95,6 +96,7 @@ class PropertyMappingViewSet( "description": subclass.__doc__, "component": subclass().component, "model_name": subclass._meta.model_name, + "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), } ) return Response(TypeCreateSerializer(data, many=True).data) diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py index a5095dcde..6c0f4db06 100644 --- a/authentik/core/api/providers.py +++ b/authentik/core/api/providers.py @@ -16,6 +16,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.models import Provider +from authentik.enterprise.apps import EnterpriseConfig from authentik.lib.utils.reflection import all_subclasses @@ -113,6 +114,7 @@ class ProviderViewSet( "description": subclass.__doc__, "component": subclass().component, "model_name": subclass._meta.model_name, + "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), } ) data.append( diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index 292f38cd3..eff2c9211 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -38,7 +38,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): managed = ReadOnlyField() component = SerializerMethodField() - icon = ReadOnlyField(source="get_icon") + icon = ReadOnlyField(source="icon_url") def get_component(self, obj: Source) -> str: """Get object component so that we know how to edit the object""" diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index d4adacc97..5b6a4a199 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -32,13 +32,7 @@ from drf_spectacular.utils import ( ) from guardian.shortcuts import get_anonymous_user, get_objects_for_user from rest_framework.decorators import action -from rest_framework.fields import ( - CharField, - IntegerField, - JSONField, - ListField, - SerializerMethodField, -) +from rest_framework.fields import CharField, IntegerField, ListField, SerializerMethodField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ( @@ -57,7 +51,7 @@ from authentik.admin.api.metrics import CoordinateSerializer from authentik.api.decorators import permission_required from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict +from authentik.core.api.utils import JSONDictField, LinkSerializer, PassiveSerializer from authentik.core.middleware import ( SESSION_KEY_IMPERSONATE_ORIGINAL_USER, SESSION_KEY_IMPERSONATE_USER, @@ -89,7 +83,7 @@ LOGGER = get_logger() class UserGroupSerializer(ModelSerializer): """Simplified Group Serializer for user's groups""" - attributes = JSONField(required=False) + attributes = JSONDictField(required=False) parent_name = CharField(source="parent.name", read_only=True) class Meta: @@ -110,7 +104,7 @@ class UserSerializer(ModelSerializer): is_superuser = BooleanField(read_only=True) avatar = CharField(read_only=True) - attributes = JSONField(validators=[is_dict], required=False) + attributes = JSONDictField(required=False) groups = PrimaryKeyRelatedField( allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all(), default=list ) @@ -171,6 +165,11 @@ class UserSerializer(ModelSerializer): raise ValidationError("Setting a user to internal service account is not allowed.") return user_type + def validate(self, attrs: dict) -> dict: + if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT: + raise ValidationError("Can't modify internal service account users") + return super().validate(attrs) + class Meta: model = User fields = [ diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index cf1870197..c79fec22e 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -2,7 +2,10 @@ from typing import Any from django.db.models import Model -from rest_framework.fields import CharField, IntegerField, JSONField +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.plumbing import build_basic_type +from drf_spectacular.types import OpenApiTypes +from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError @@ -13,6 +16,21 @@ def is_dict(value: Any): raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") +class JSONDictField(JSONField): + """JSON Field which only allows dictionaries""" + + default_validators = [is_dict] + + +class JSONExtension(OpenApiSerializerFieldExtension): + """Generate API Schema for JSON fields as""" + + target_class = "authentik.core.api.utils.JSONDictField" + + def map_serializer_field(self, auto_schema, direction): + return build_basic_type(OpenApiTypes.OBJECT) + + class PassiveSerializer(Serializer): """Base serializer class which doesn't implement create/update methods""" @@ -26,7 +44,7 @@ class PassiveSerializer(Serializer): class PropertyMappingPreviewSerializer(PassiveSerializer): """Preview how the current user is mapped via the property mappings selected in a provider""" - preview = JSONField(read_only=True) + preview = JSONDictField(read_only=True) class MetaNameSerializer(PassiveSerializer): @@ -56,6 +74,7 @@ class TypeCreateSerializer(PassiveSerializer): description = CharField(required=True) component = CharField(required=True) model_name = CharField(required=True) + requires_enterprise = BooleanField(default=False) class CacheSerializer(PassiveSerializer): diff --git a/authentik/core/channels.py b/authentik/core/channels.py index 00f213efc..722e9e03f 100644 --- a/authentik/core/channels.py +++ b/authentik/core/channels.py @@ -1,22 +1,29 @@ """Channels base classes""" +from channels.db import database_sync_to_async from channels.exceptions import DenyConnection -from channels.generic.websocket import JsonWebsocketConsumer from rest_framework.exceptions import AuthenticationFailed from structlog.stdlib import get_logger from authentik.api.authentication import bearer_auth -from authentik.core.models import User LOGGER = get_logger() -class AuthJsonConsumer(JsonWebsocketConsumer): +class TokenOutpostMiddleware: """Authorize a client with a token""" - user: User + def __init__(self, inner): + self.inner = inner - def connect(self): - headers = dict(self.scope["headers"]) + async def __call__(self, scope, receive, send): + scope = dict(scope) + await self.auth(scope) + return await self.inner(scope, receive, send) + + @database_sync_to_async + def auth(self, scope): + """Authenticate request from header""" + headers = dict(scope["headers"]) if b"authorization" not in headers: LOGGER.warning("WS Request without authorization header") raise DenyConnection() @@ -32,4 +39,4 @@ class AuthJsonConsumer(JsonWebsocketConsumer): LOGGER.warning("Failed to authenticate", exc=exc) raise DenyConnection() - self.user = user + scope["user"] = user diff --git a/authentik/core/expression/evaluator.py b/authentik/core/expression/evaluator.py index 85e6ccbc4..480caea21 100644 --- a/authentik/core/expression/evaluator.py +++ b/authentik/core/expression/evaluator.py @@ -44,6 +44,7 @@ class PropertyMappingEvaluator(BaseEvaluator): if request: req.http_request = request self._context["request"] = req + req.context.update(**kwargs) self._context.update(**kwargs) self.dry_run = dry_run diff --git a/authentik/core/management/commands/worker.py b/authentik/core/management/commands/worker.py index b22187efe..f85c36cc9 100644 --- a/authentik/core/management/commands/worker.py +++ b/authentik/core/management/commands/worker.py @@ -17,9 +17,15 @@ class Command(BaseCommand): """Run worker""" def add_arguments(self, parser): - parser.add_argument("-b", "--beat", action="store_true") + parser.add_argument( + "-b", + "--beat", + action="store_false", + help="When set, this worker will _not_ run Beat (scheduled) tasks", + ) def handle(self, **options): + LOGGER.debug("Celery options", **options) close_old_connections() if CONFIG.get_bool("remote_debug"): import debugpy diff --git a/authentik/core/models.py b/authentik/core/models.py index 5365ef693..125d5b0c8 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -30,7 +30,6 @@ from authentik.lib.models import ( DomainlessFormattedURLValidator, SerializerModel, ) -from authentik.lib.utils.http import get_client_ip from authentik.policies.models import PolicyBindingModel from authentik.root.install_id import get_install_id @@ -517,7 +516,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): objects = InheritanceManager() @property - def get_icon(self) -> Optional[str]: + def icon_url(self) -> Optional[str]: """Get the URL to the Icon. If the name is /static or starts with http it is returned as-is""" if not self.icon: @@ -748,12 +747,14 @@ class AuthenticatedSession(ExpiringModel): @staticmethod def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: """Create a new session from a http request""" + from authentik.root.middleware import ClientIPMiddleware + if not hasattr(request, "session") or not request.session.session_key: return None return AuthenticatedSession( session_key=request.session.session_key, user=user, - last_ip=get_client_ip(request), + last_ip=ClientIPMiddleware.get_client_ip(request), last_user_agent=request.META.get("HTTP_USER_AGENT", ""), expires=request.session.get_expiry_date(), ) diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html index 50a40de66..85137cc42 100644 --- a/authentik/core/templates/base/skeleton.html +++ b/authentik/core/templates/base/skeleton.html @@ -13,7 +13,6 @@ {% block head_before %} {% endblock %} - diff --git a/authentik/core/templates/if/flow.html b/authentik/core/templates/if/flow.html index da117a470..197c3ffda 100644 --- a/authentik/core/templates/if/flow.html +++ b/authentik/core/templates/if/flow.html @@ -27,7 +27,7 @@ window.authentik.flow = { {% block body %} - + {% endblock %} diff --git a/authentik/core/templates/login/base_full.html b/authentik/core/templates/login/base_full.html index 4cbf5e8dc..be6e3a040 100644 --- a/authentik/core/templates/login/base_full.html +++ b/authentik/core/templates/login/base_full.html @@ -6,6 +6,7 @@ {% block head_before %} + {% include "base/header_js.html" %} {% endblock %} @@ -43,28 +44,14 @@ {% block body %}
- - - - - - - - - - -
-
diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index f5b59efe5..74f3acbeb 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -1,5 +1,6 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-radio-input"; @@ -7,7 +8,6 @@ import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/utils/TimeDeltaHelp"; @@ -116,7 +116,7 @@ export const redirectUriHelp = html`${redirectUriHelpMessages.map( */ @customElement("ak-provider-oauth2-form") -export class OAuth2ProviderFormPage extends ModelForm { +export class OAuth2ProviderFormPage extends BaseProviderForm { propertyMappings?: PaginatedScopeMappingList; oauthSources?: PaginatedOAuthSourceList; @@ -143,14 +143,6 @@ export class OAuth2ProviderFormPage extends ModelForm { }); } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated provider."); - } else { - return msg("Successfully created provider."); - } - } - async send(data: OAuth2Provider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersOauth2Update({ @@ -298,9 +290,13 @@ export class OAuth2ProviderFormPage extends ModelForm { let selected = false; if (!provider?.propertyMappings) { selected = - scope.managed?.startsWith( + // By default select all managed scope mappings, except offline_access + (scope.managed?.startsWith( "goauthentik.io/providers/oauth2/scope-", - ) || false; + ) && + scope.managed !== + "goauthentik.io/providers/oauth2/scope-offline_access") || + false; } else { selected = Array.from(provider?.propertyMappings).some((su) => { return su == scope.pk; diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 9886c9be3..ddd554572 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -1,11 +1,11 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-toggle-group"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/utils/TimeDeltaHelp"; @@ -30,7 +30,7 @@ import { } from "@goauthentik/api"; @customElement("ak-provider-proxy-form") -export class ProxyProviderFormPage extends ModelForm { +export class ProxyProviderFormPage extends BaseProviderForm { static get styles(): CSSResult[] { return [...super.styles, PFContent, PFList, PFSpacing]; } @@ -65,14 +65,6 @@ export class ProxyProviderFormPage extends ModelForm { @state() mode: ProxyMode = ProxyMode.Proxy; - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated provider."); - } else { - return msg("Successfully created provider."); - } - } - async send(data: ProxyProvider): Promise { data.mode = this.mode; if (this.mode !== ProxyMode.ForwardDomain) { @@ -324,7 +316,7 @@ export class ProxyProviderFormPage extends ModelForm {
- - ${this.provider.basicAuthEnabled - ? msg("Yes") - : msg("No")} - +
diff --git a/web/src/admin/providers/rac/EndpointForm.ts b/web/src/admin/providers/rac/EndpointForm.ts new file mode 100644 index 000000000..0f23f4fca --- /dev/null +++ b/web/src/admin/providers/rac/EndpointForm.ts @@ -0,0 +1,163 @@ +import { first } from "@goauthentik/app/common/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import YAML from "yaml"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + AuthModeEnum, + Endpoint, + PaginatedRACPropertyMappingList, + PropertymappingsApi, + ProtocolEnum, + RacApi, +} from "@goauthentik/api"; + +@customElement("ak-rac-endpoint-form") +export class EndpointForm extends ModelForm { + @property({ type: Number }) + providerID?: number; + + propertyMappings?: PaginatedRACPropertyMappingList; + + async load(): Promise { + this.propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsRacList({ + ordering: "name", + }); + } + + loadInstance(pk: string): Promise { + return new RacApi(DEFAULT_CONFIG).racEndpointsRetrieve({ + pbmUuid: pk, + }); + } + + getSuccessMessage(): string { + return this.instance + ? msg("Successfully updated endpoint.") + : msg("Successfully created endpoint."); + } + + async send(data: Endpoint): Promise { + data.authMode = AuthModeEnum.Prompt; + if (!this.instance) { + data.provider = this.providerID || 0; + } else { + data.provider = this.instance.provider; + } + if (this.instance) { + return new RacApi(DEFAULT_CONFIG).racEndpointsPartialUpdate({ + pbmUuid: this.instance.pk || "", + patchedEndpointRequest: data, + }); + } else { + return new RacApi(DEFAULT_CONFIG).racEndpointsCreate({ + endpointRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + + + + + +

${msg("Hostname/IP to connect to.")}

+
+ + +

+ ${msg( + "Maximum concurrent allowed connections to this endpoint. Can be set to -1 to disable the limit.", + )} +

+
+ + +

+ ${msg("Hold control/command to select multiple items.")} +

+
+ + ${msg("Advanced settings")} +
+ + + +

${msg("Connection settings.")}

+
+
+
+ `; + } +} diff --git a/web/src/admin/providers/rac/EndpointList.ts b/web/src/admin/providers/rac/EndpointList.ts new file mode 100644 index 000000000..d3c3f88c3 --- /dev/null +++ b/web/src/admin/providers/rac/EndpointList.ts @@ -0,0 +1,142 @@ +import "@goauthentik/admin/policies/BoundPoliciesList"; +import "@goauthentik/app/admin/providers/rac/EndpointForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { uiConfig } from "@goauthentik/common/ui/config"; +import "@goauthentik/elements/buttons/SpinnerButton"; +import "@goauthentik/elements/forms/DeleteBulkForm"; +import "@goauthentik/elements/forms/ModalForm"; +import "@goauthentik/elements/rbac/ObjectPermissionModal"; +import { PaginatedResponse, Table } from "@goauthentik/elements/table/Table"; +import { TableColumn } from "@goauthentik/elements/table/Table"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; + +import { + Endpoint, + RACProvider, + RacApi, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; + +@customElement("ak-rac-endpoint-list") +export class EndpointListPage extends Table { + expandable = true; + checkbox = true; + + searchEnabled(): boolean { + return true; + } + + @property() + order = "name"; + + @property({ attribute: false }) + provider?: RACProvider; + + static get styles(): CSSResult[] { + return super.styles.concat(PFDescriptionList); + } + + async apiEndpoint(page: number): Promise> { + return new RacApi(DEFAULT_CONFIG).racEndpointsList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + provider: this.provider?.pk, + superuserFullList: true, + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn(msg("Name"), "name"), + new TableColumn(msg("Host"), "host"), + new TableColumn(msg("Actions")), + ]; + } + + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return [ + { key: msg("Name"), value: item.name }, + { key: msg("Host"), value: item.host }, + ]; + }} + .usedBy=${(item: Endpoint) => { + return new RacApi(DEFAULT_CONFIG).racEndpointsUsedByList({ + pbmUuid: item.pk, + }); + }} + .delete=${(item: Endpoint) => { + return new RacApi(DEFAULT_CONFIG).racEndpointsDestroy({ + pbmUuid: item.pk, + }); + }} + > + + `; + } + + row(item: Endpoint): TemplateResult[] { + return [ + html`${item.name}`, + html`${item.host}`, + html` + ${msg("Update")} + ${msg("Update Endpoint")} + + + + + + `, + ]; + } + + renderExpanded(item: Endpoint): TemplateResult { + return html` + +
+
+

+ ${msg( + "These bindings control which users will have access to this endpoint. Users must also have access to the application.", + )} +

+ +
+
+ `; + } + + renderObjectCreate(): TemplateResult { + return html` + + ${msg("Create")} + ${msg("Create Endpoint")} + + + + + `; + } +} diff --git a/web/src/admin/providers/rac/RACProviderForm.ts b/web/src/admin/providers/rac/RACProviderForm.ts new file mode 100644 index 000000000..53a5357a9 --- /dev/null +++ b/web/src/admin/providers/rac/RACProviderForm.ts @@ -0,0 +1,158 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; +import { first } from "@goauthentik/app/common/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; +import YAML from "yaml"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + FlowsInstancesListDesignationEnum, + PaginatedEndpointList, + PaginatedRACPropertyMappingList, + PropertymappingsApi, + ProvidersApi, + RACProvider, + RacApi, +} from "@goauthentik/api"; + +@customElement("ak-provider-rac-form") +export class RACProviderFormPage extends ModelForm { + @state() + endpoints?: PaginatedEndpointList; + + propertyMappings?: PaginatedRACPropertyMappingList; + + async load(): Promise { + this.endpoints = await new RacApi(DEFAULT_CONFIG).racEndpointsList({}); + this.propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsRacList({ + ordering: "name", + }); + } + + async loadInstance(pk: number): Promise { + return new ProvidersApi(DEFAULT_CONFIG).providersRacRetrieve({ + id: pk, + }); + } + + getSuccessMessage(): string { + if (this.instance) { + return msg("Successfully updated provider."); + } else { + return msg("Successfully created provider."); + } + } + + async send(data: RACProvider): Promise { + if (this.instance) { + return new ProvidersApi(DEFAULT_CONFIG).providersRacUpdate({ + id: this.instance.pk || 0, + rACProviderRequest: data, + }); + } else { + return new ProvidersApi(DEFAULT_CONFIG).providersRacCreate({ + rACProviderRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg( + "Determines how long a session lasts before being disconnected and requiring re-authorization.", + )} +

+ +
+ + + ${msg("Protocol settings")} +
+ + +

+ ${msg("Hold control/command to select multiple items.")} +

+
+ + + +

${msg("Connection settings.")}

+
+
+
+ `; + } +} diff --git a/web/src/admin/providers/rac/RACProviderViewPage.ts b/web/src/admin/providers/rac/RACProviderViewPage.ts new file mode 100644 index 000000000..393fa4375 --- /dev/null +++ b/web/src/admin/providers/rac/RACProviderViewPage.ts @@ -0,0 +1,181 @@ +import "@goauthentik/admin/providers/RelatedApplicationButton"; +import "@goauthentik/admin/providers/rac/EndpointForm"; +import "@goauthentik/admin/providers/rac/EndpointList"; +import "@goauthentik/admin/providers/rac/RACProviderForm"; +import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import "@goauthentik/components/ak-status-label"; +import "@goauthentik/components/events/ObjectChangelog"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/Tabs"; +import "@goauthentik/elements/buttons/ModalButton"; +import "@goauthentik/elements/buttons/SpinnerButton"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { + ProvidersApi, + RACProvider, + RbacPermissionsAssignedByUsersListModelEnum, +} from "@goauthentik/api"; + +@customElement("ak-provider-rac-view") +export class RACProviderViewPage extends AKElement { + @property() + set args(value: { [key: string]: number }) { + this.providerID = value.id; + } + + @property({ type: Number }) + set providerID(value: number) { + new ProvidersApi(DEFAULT_CONFIG) + .providersRacRetrieve({ + id: value, + }) + .then((prov) => (this.provider = prov)); + } + + @property({ attribute: false }) + provider?: RACProvider; + + static get styles(): CSSResult[] { + return [ + PFBase, + PFButton, + PFPage, + PFGrid, + PFContent, + PFList, + PFForm, + PFFormControl, + PFCard, + PFDescriptionList, + PFBanner, + ]; + } + + constructor() { + super(); + this.addEventListener(EVENT_REFRESH, () => { + if (!this.provider?.pk) return; + this.providerID = this.provider?.pk; + }); + } + + render(): TemplateResult { + if (!this.provider) { + return html``; + } + return html` +
+ ${this.renderTabOverview()} +
+
+
+
+ + +
+
+
+ +
`; + } + + renderTabOverview(): TemplateResult { + if (!this.provider) { + return html``; + } + return html`
+ ${msg("RAC is in preview.")} + ${msg("Send us feedback!")} +
+ ${this.provider?.assignedApplicationName + ? html`` + : html`
+ ${msg("Warning: Provider is not used by an Application.")} +
`} + ${this.provider?.outpostSet.length < 1 + ? html`
+ ${msg("Warning: Provider is not used by any Outpost.")} +
` + : html``} +
+
+
+
+
+
+ ${msg("Name")} +
+
+
+ ${this.provider.name} +
+
+
+
+
+ ${msg("Assigned to application")} +
+
+
+ +
+
+
+
+
+ +
+
+
${msg("Endpoints")}
+
+ +
+
+
`; + } +} diff --git a/web/src/admin/providers/radius/RadiusProviderForm.ts b/web/src/admin/providers/radius/RadiusProviderForm.ts index 3898ba048..f37c865d5 100644 --- a/web/src/admin/providers/radius/RadiusProviderForm.ts +++ b/web/src/admin/providers/radius/RadiusProviderForm.ts @@ -1,9 +1,9 @@ +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; -import { rootInterface } from "@goauthentik/elements/Base"; +import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; import { msg } from "@lit/localize"; @@ -14,21 +14,13 @@ import { customElement } from "lit/decorators.js"; import { FlowsInstancesListDesignationEnum, ProvidersApi, RadiusProvider } from "@goauthentik/api"; @customElement("ak-provider-radius-form") -export class RadiusProviderFormPage extends ModelForm { +export class RadiusProviderFormPage extends WithTenantConfig(BaseProviderForm) { loadInstance(pk: number): Promise { return new ProvidersApi(DEFAULT_CONFIG).providersRadiusRetrieve({ id: pk, }); } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated provider."); - } else { - return msg("Successfully created provider."); - } - } - async send(data: RadiusProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersRadiusUpdate({ @@ -65,7 +57,7 @@ export class RadiusProviderFormPage extends ModelForm {

${msg("Flow used for users to authenticate.")}

diff --git a/web/src/admin/providers/saml/SAMLProviderForm.ts b/web/src/admin/providers/saml/SAMLProviderForm.ts index c48993e84..006a32545 100644 --- a/web/src/admin/providers/saml/SAMLProviderForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderForm.ts @@ -1,9 +1,9 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/utils/TimeDeltaHelp"; @@ -27,7 +27,7 @@ import { } from "@goauthentik/api"; @customElement("ak-provider-saml-form") -export class SAMLProviderFormPage extends ModelForm { +export class SAMLProviderFormPage extends BaseProviderForm { loadInstance(pk: number): Promise { return new ProvidersApi(DEFAULT_CONFIG).providersSamlRetrieve({ id: pk, @@ -44,14 +44,6 @@ export class SAMLProviderFormPage extends ModelForm { propertyMappings?: PaginatedSAMLPropertyMappingList; - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated provider."); - } else { - return msg("Successfully created provider."); - } - } - async send(data: SAMLProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersSamlUpdate({ @@ -175,7 +167,7 @@ export class SAMLProviderFormPage extends ModelForm { name="signingKp" >

${msg( @@ -188,7 +180,7 @@ export class SAMLProviderFormPage extends ModelForm { name="verificationKp" >

diff --git a/web/src/admin/providers/scim/SCIMProviderForm.ts b/web/src/admin/providers/scim/SCIMProviderForm.ts index cdc935973..e505c4b23 100644 --- a/web/src/admin/providers/scim/SCIMProviderForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderForm.ts @@ -1,8 +1,8 @@ +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/SearchSelect"; @@ -22,7 +22,7 @@ import { } from "@goauthentik/api"; @customElement("ak-provider-scim-form") -export class SCIMProviderFormPage extends ModelForm { +export class SCIMProviderFormPage extends BaseProviderForm { loadInstance(pk: number): Promise { return new ProvidersApi(DEFAULT_CONFIG).providersScimRetrieve({ id: pk, @@ -39,14 +39,6 @@ export class SCIMProviderFormPage extends ModelForm { propertyMappings?: PaginatedSCIMMappingList; - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated provider."); - } else { - return msg("Successfully created provider."); - } - } - async send(data: SCIMProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({ diff --git a/web/src/admin/providers/scim/SCIMProviderViewPage.ts b/web/src/admin/providers/scim/SCIMProviderViewPage.ts index 3998c7c81..d745ed55e 100644 --- a/web/src/admin/providers/scim/SCIMProviderViewPage.ts +++ b/web/src/admin/providers/scim/SCIMProviderViewPage.ts @@ -10,7 +10,7 @@ import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ModalButton"; -import { msg } from "@lit/localize"; +import { msg, str } from "@lit/localize"; import { CSSResult, TemplateResult, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; @@ -31,7 +31,8 @@ import { ProvidersApi, RbacPermissionsAssignedByUsersListModelEnum, SCIMProvider, - Task, + SCIMSyncStatus, + TaskStatusEnum, } from "@goauthentik/api"; @customElement("ak-provider-scim-view") @@ -54,7 +55,7 @@ export class SCIMProviderViewPage extends AKElement { provider?: SCIMProvider; @state() - syncState?: Task; + syncState?: SCIMSyncStatus; static get styles(): CSSResult[] { return [ @@ -128,6 +129,41 @@ export class SCIMProviderViewPage extends AKElement { `; } + renderSyncStatus(): TemplateResult { + if (!this.syncState) { + return html`${msg("No sync status.")}`; + } + if (this.syncState.isRunning) { + return html`${msg("Sync currently running.")}`; + } + if (this.syncState.tasks.length < 1) { + return html`${msg("Not synced yet.")}`; + } + return html` +

    + ${this.syncState.tasks.map((task) => { + let header = ""; + if (task.status === TaskStatusEnum.Warning) { + header = msg("Task finished with warnings"); + } else if (task.status === TaskStatusEnum.Error) { + header = msg("Task finished with errors"); + } else { + header = msg(str`Last sync: ${task.taskFinishTimestamp.toLocaleString()}`); + } + return html`
  • +

    ${task.taskName}

    +
      +
    • ${header}
    • + ${task.messages.map((m) => { + return html`
    • ${m}
    • `; + })} +
    +
  • `; + })} +
+ `; + } + renderTabOverview(): TemplateResult { if (!this.provider) { return html``; @@ -186,16 +222,7 @@ export class SCIMProviderViewPage extends AKElement {

${msg("Sync status")}

-
- ${this.syncState - ? html`
    - ${this.syncState.messages.map((m) => { - return html`
  • ${m}
  • `; - })} -
` - : html` ${msg("Sync not run yet.")} `} -
- +
${this.renderSyncStatus()}
-
+
+
+

${msg("Connectivity")}

+
+
+ +
+
+

${msg("Sync status")}

-
- ${this.syncState.length < 1 - ? html`

${msg("Not synced yet.")}

` - : html` -
    - ${this.syncState.map((task) => { - let header = ""; - if (task.status === TaskStatusEnum.Warning) { - header = msg("Task finished with warnings"); - } else if (task.status === TaskStatusEnum.Error) { - header = msg("Task finished with errors"); - } else { - header = msg( - str`Last sync: ${task.taskFinishTimestamp.toLocaleString()}`, - ); - } - return html`
  • -

    ${task.taskName}

    -
      -
    • ${header}
    • - ${task.messages.map((m) => { - return html`
    • ${m}
    • `; - })} -
    -
  • `; - })} -
- `} -
+
${this.renderSyncStatus()}
`; @@ -325,7 +320,7 @@ export class OAuthSourceForm extends ModelForm { />

${placeholderHelperText}

- ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.icon @@ -386,6 +381,7 @@ export class OAuthSourceForm extends ModelForm { class="pf-c-form-control" required /> +

${msg("Also known as Client ID.")}

{ name="consumerSecret" > +

${msg("Also known as Client Secret.")}

{ +export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({ slug: pk, @@ -50,14 +52,6 @@ export class PlexSourceForm extends ModelForm { } as PlexSource; } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated source."); - } else { - return msg("Successfully created source."); - } - } - async send(data: PlexSource): Promise { data.plexToken = this.plexToken || ""; let source: PlexSource; @@ -71,8 +65,7 @@ export class PlexSourceForm extends ModelForm { plexSourceRequest: data, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { + if (this.can(CapabilitiesEnum.CanSaveMedia)) { const icon = this.getFormFiles()["icon"]; if (icon || this.clearIcon) { await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ @@ -263,7 +256,7 @@ export class PlexSourceForm extends ModelForm { />

${placeholderHelperText}

- ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.icon diff --git a/web/src/admin/sources/saml/SAMLSourceForm.ts b/web/src/admin/sources/saml/SAMLSourceForm.ts index 9e9fb8392..c969411fb 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -1,13 +1,16 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; +import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; -import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/utils/TimeDeltaHelp"; @@ -18,7 +21,6 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { BindingTypeEnum, - CapabilitiesEnum, DigestAlgorithmEnum, FlowsInstancesListDesignationEnum, NameIdPolicyEnum, @@ -29,7 +31,7 @@ import { } from "@goauthentik/api"; @customElement("ak-source-saml-form") -export class SAMLSourceForm extends ModelForm { +export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { @state() clearIcon = false; @@ -41,14 +43,6 @@ export class SAMLSourceForm extends ModelForm { return source; } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated source."); - } else { - return msg("Successfully created source."); - } - } - async send(data: SAMLSource): Promise { let source: SAMLSource; if (this.instance) { @@ -157,7 +151,7 @@ export class SAMLSourceForm extends ModelForm { - ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.icon @@ -272,7 +266,7 @@ export class SAMLSourceForm extends ModelForm {

${msg( @@ -285,7 +279,7 @@ export class SAMLSourceForm extends ModelForm { name="verificationKp" >

diff --git a/web/src/admin/stages/BaseStageForm.ts b/web/src/admin/stages/BaseStageForm.ts new file mode 100644 index 000000000..67c5ffc35 --- /dev/null +++ b/web/src/admin/stages/BaseStageForm.ts @@ -0,0 +1,11 @@ +import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; + +import { msg } from "@lit/localize"; + +export abstract class BaseStageForm extends ModelForm { + getSuccessMessage(): string { + return this.instance + ? msg("Successfully updated stage.") + : msg("Successfully created stage."); + } +} diff --git a/web/src/admin/stages/authenticator_duo/AuthenticatorDuoStageForm.ts b/web/src/admin/stages/authenticator_duo/AuthenticatorDuoStageForm.ts index 4d74059be..db9322a84 100644 --- a/web/src/admin/stages/authenticator_duo/AuthenticatorDuoStageForm.ts +++ b/web/src/admin/stages/authenticator_duo/AuthenticatorDuoStageForm.ts @@ -1,9 +1,9 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; import { msg } from "@lit/localize"; @@ -21,21 +21,13 @@ import { } from "@goauthentik/api"; @customElement("ak-stage-authenticator-duo-form") -export class AuthenticatorDuoStageForm extends ModelForm { +export class AuthenticatorDuoStageForm extends BaseStageForm { loadInstance(pk: string): Promise { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorDuoRetrieve({ stageUuid: pk, }); } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated stage."); - } else { - return msg("Successfully created stage."); - } - } - async send(data: AuthenticatorDuoStage): Promise { if (this.instance) { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorDuoPartialUpdate({ diff --git a/web/src/admin/stages/authenticator_sms/AuthenticatorSMSStageForm.ts b/web/src/admin/stages/authenticator_sms/AuthenticatorSMSStageForm.ts index 4522d050b..2938b156a 100644 --- a/web/src/admin/stages/authenticator_sms/AuthenticatorSMSStageForm.ts +++ b/web/src/admin/stages/authenticator_sms/AuthenticatorSMSStageForm.ts @@ -1,9 +1,9 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/SearchSelect"; @@ -26,7 +26,7 @@ import { } from "@goauthentik/api"; @customElement("ak-stage-authenticator-sms-form") -export class AuthenticatorSMSStageForm extends ModelForm { +export class AuthenticatorSMSStageForm extends BaseStageForm { loadInstance(pk: string): Promise { return new StagesApi(DEFAULT_CONFIG) .stagesAuthenticatorSmsRetrieve({ @@ -45,14 +45,6 @@ export class AuthenticatorSMSStageForm extends ModelForm { if (this.instance) { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorSmsUpdate({ diff --git a/web/src/admin/stages/authenticator_static/AuthenticatorStaticStageForm.ts b/web/src/admin/stages/authenticator_static/AuthenticatorStaticStageForm.ts index f9f2684d0..7a5dcab47 100644 --- a/web/src/admin/stages/authenticator_static/AuthenticatorStaticStageForm.ts +++ b/web/src/admin/stages/authenticator_static/AuthenticatorStaticStageForm.ts @@ -1,8 +1,8 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; import { msg } from "@lit/localize"; @@ -19,21 +19,13 @@ import { } from "@goauthentik/api"; @customElement("ak-stage-authenticator-static-form") -export class AuthenticatorStaticStageForm extends ModelForm { +export class AuthenticatorStaticStageForm extends BaseStageForm { loadInstance(pk: string): Promise { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorStaticRetrieve({ stageUuid: pk, }); } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated stage."); - } else { - return msg("Successfully created stage."); - } - } - async send(data: AuthenticatorStaticStage): Promise { if (this.instance) { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorStaticUpdate({ diff --git a/web/src/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm.ts b/web/src/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm.ts index ff44f9349..91ddcab19 100644 --- a/web/src/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm.ts +++ b/web/src/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm.ts @@ -1,9 +1,9 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; -import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/SearchSelect"; import { msg } from "@lit/localize"; @@ -21,21 +21,13 @@ import { } from "@goauthentik/api"; @customElement("ak-stage-authenticator-totp-form") -export class AuthenticatorTOTPStageForm extends ModelForm { +export class AuthenticatorTOTPStageForm extends BaseStageForm { loadInstance(pk: string): Promise { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorTotpRetrieve({ stageUuid: pk, }); } - getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated stage."); - } else { - return msg("Successfully created stage."); - } - } - async send(data: AuthenticatorTOTPStage): Promise { if (this.instance) { return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorTotpUpdate({ @@ -89,14 +81,14 @@ export class AuthenticatorTOTPStageForm extends ModelForm + + + + + + ${msg("Pretend user exists")} + +

+ ${msg( + "When enabled, the stage will always accept the given user identifier and continue.", + )} +

+
+ + + +

+ ${msg( + "Configure if sessions created by this stage should be bound to the Networks they were created in.", + )} +

+
+ + + +

+ ${msg( + "Configure if sessions created by this stage should be bound to their GeoIP-based location", + )} +

+
{ row(item: Tenant): TemplateResult[] { return [ html`${item.domain}`, - html` - ${item._default ? msg("Yes") : msg("No")} - `, + html``, html` ${msg("Update")} ${msg("Update Tenant")} diff --git a/web/src/admin/tokens/TokenForm.ts b/web/src/admin/tokens/TokenForm.ts index 904c8cdac..7324b0e70 100644 --- a/web/src/admin/tokens/TokenForm.ts +++ b/web/src/admin/tokens/TokenForm.ts @@ -26,11 +26,9 @@ export class TokenForm extends ModelForm { } getSuccessMessage(): string { - if (this.instance) { - return msg("Successfully updated token."); - } else { - return msg("Successfully created token."); - } + return this.instance + ? msg("Successfully updated token.") + : msg("Successfully created token."); } async send(data: Token): Promise { diff --git a/web/src/admin/tokens/TokenListPage.ts b/web/src/admin/tokens/TokenListPage.ts index ec4d1018d..953cd17dd 100644 --- a/web/src/admin/tokens/TokenListPage.ts +++ b/web/src/admin/tokens/TokenListPage.ts @@ -2,7 +2,7 @@ import "@goauthentik/admin/tokens/TokenForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { intentToLabel } from "@goauthentik/common/labels"; import { uiConfig } from "@goauthentik/common/ui/config"; -import { PFColor } from "@goauthentik/elements/Label"; +import "@goauthentik/components/ak-status-label"; import "@goauthentik/elements/buttons/Dropdown"; import "@goauthentik/elements/buttons/TokenCopyButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; @@ -109,9 +109,7 @@ export class TokenListPage extends TablePage { ? html`${msg("Token is managed by authentik.")}` : html``}`, html`${item.userObj?.username}`, - html` - ${item.expiring ? msg("Yes") : msg("No")} - `, + html``, html`${item.expiring ? item.expires?.toLocaleString() : msg("-")}`, html`${intentToLabel(item.intent ?? IntentEnum.Api)}`, html` diff --git a/web/src/admin/users/GroupSelectModal.ts b/web/src/admin/users/GroupSelectModal.ts index 350095c9b..eac99d4ae 100644 --- a/web/src/admin/users/GroupSelectModal.ts +++ b/web/src/admin/users/GroupSelectModal.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { uiConfig } from "@goauthentik/common/ui/config"; -import { PFColor } from "@goauthentik/elements/Label"; +import "@goauthentik/components/ak-status-label"; import "@goauthentik/elements/buttons/SpinnerButton"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table"; @@ -54,9 +54,7 @@ export class GroupSelectModal extends TableModal { html`
${item.name}
`, - html` - ${item.isSuperuser ? msg("Yes") : msg("No")} - `, + html` `, html`${(item.users || []).length}`, ]; } diff --git a/web/src/admin/users/ServiceAccountForm.ts b/web/src/admin/users/ServiceAccountForm.ts index 96dbb89be..914e5fd51 100644 --- a/web/src/admin/users/ServiceAccountForm.ts +++ b/web/src/admin/users/ServiceAccountForm.ts @@ -4,19 +4,30 @@ import { Form } from "@goauthentik/elements/forms/Form"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModalForm } from "@goauthentik/elements/forms/ModalForm"; -import { msg } from "@lit/localize"; +import { msg, str } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { CoreApi, UserServiceAccountRequest, UserServiceAccountResponse } from "@goauthentik/api"; +import { + CoreApi, + Group, + UserServiceAccountRequest, + UserServiceAccountResponse, +} from "@goauthentik/api"; -@customElement("ak-user-service-account") +@customElement("ak-user-service-account-form") export class ServiceAccountForm extends Form { @property({ attribute: false }) result?: UserServiceAccountResponse; + @property({ attribute: false }) + group?: Group; + getSuccessMessage(): string { + if (this.group) { + return msg(str`Successfully created user and added to group ${this.group.name}`); + } return msg("Successfully created user."); } @@ -26,6 +37,14 @@ export class ServiceAccountForm extends Form { }); this.result = result; (this.parentElement as ModalForm).showSubmitButton = false; + if (this.group) { + await new CoreApi(DEFAULT_CONFIG).coreGroupsAddUserCreate({ + groupUuid: this.group.pk, + userAccountRequest: { + pk: this.result.userPk, + }, + }); + } return result; } diff --git a/web/src/admin/users/UserDevicesTable.ts b/web/src/admin/users/UserDevicesTable.ts index b120c3265..06ead21df 100644 --- a/web/src/admin/users/UserDevicesTable.ts +++ b/web/src/admin/users/UserDevicesTable.ts @@ -15,6 +15,8 @@ export class UserDeviceTable extends Table { @property({ type: Number }) userId?: number; + checkbox = true; + async apiEndpoint(): Promise> { return new AuthenticatorsApi(DEFAULT_CONFIG) .authenticatorsAdminAllList({ @@ -64,6 +66,21 @@ export class UserDeviceTable extends Table { } } + renderToolbarSelected(): TemplateResult { + const disabled = this.selectedElements.length < 1; + return html` { + return this.deleteWrapper(item); + }} + > + + `; + } + renderToolbar(): TemplateResult { return html` { diff --git a/web/src/admin/users/UserForm.ts b/web/src/admin/users/UserForm.ts index 13d2ac141..061fd6f56 100644 --- a/web/src/admin/users/UserForm.ts +++ b/web/src/admin/users/UserForm.ts @@ -8,15 +8,18 @@ import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import "@goauthentik/elements/forms/Radio"; import YAML from "yaml"; -import { msg } from "@lit/localize"; +import { msg, str } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; -import { customElement } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { CoreApi, User, UserTypeEnum } from "@goauthentik/api"; +import { CoreApi, Group, User, UserTypeEnum } from "@goauthentik/api"; @customElement("ak-user-form") export class UserForm extends ModelForm { + @property({ attribute: false }) + group?: Group; + static get defaultUserAttributes(): { [key: string]: unknown } { return {}; } @@ -42,6 +45,9 @@ export class UserForm extends ModelForm { if (this.instance) { return msg("Successfully updated user."); } else { + if (this.group) { + return msg(str`Successfully created user and added to group ${this.group.name}`); + } return msg("Successfully created user."); } } @@ -50,21 +56,31 @@ export class UserForm extends ModelForm { if (data.attributes === null) { data.attributes = UserForm.defaultUserAttributes; } + let user; if (this.instance?.pk) { - return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ + user = await new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ id: this.instance.pk, patchedUserRequest: data, }); } else { data.groups = []; - return new CoreApi(DEFAULT_CONFIG).coreUsersCreate({ + user = await new CoreApi(DEFAULT_CONFIG).coreUsersCreate({ userRequest: data, }); } + if (this.group) { + await new CoreApi(DEFAULT_CONFIG).coreGroupsAddUserCreate({ + groupUuid: this.group.pk, + userAccountRequest: { + pk: user.pk, + }, + }); + } + return user; } renderForm(): TemplateResult { - return html` new CoreApi(DEFAULT_CONFIG) @@ -93,7 +91,7 @@ const recoveryButtonStyles = css` `; @customElement("ak-user-list") -export class UserListPage extends TablePage { +export class UserListPage extends WithTenantConfig(WithCapabilitiesConfig(TablePage)) { expandable = true; checkbox = true; @@ -244,16 +242,13 @@ export class UserListPage extends TablePage { row(item: User): TemplateResult[] { const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - item.pk !== this.me?.user.pk; + this.can(CapabilitiesEnum.CanImpersonate) && item.pk !== this.me?.user.pk; return [ html`
${item.username}
${item.name === "" ? msg("") : item.name}
 ${userTypeToLabel(item.type)}`, - html` - ${item.isActive ? msg("Yes") : msg("No")} - `, + html``, html`${first(item.lastLogin?.toLocaleString(), msg("-"))}`, html` ${msg("Update")} @@ -357,7 +352,7 @@ export class UserListPage extends TablePage { ${msg("Set password")} - ${rootInterface()?.tenant?.flowRecovery + ${this.tenant.flowRecovery ? html` { ${msg("Create")} ${msg("Create Service account")} - + diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index d752f645a..ddc9e92ba 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -14,11 +14,17 @@ import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { userTypeToLabel } from "@goauthentik/common/labels"; +import "@goauthentik/components/DescriptionList"; +import { + type DescriptionPair, + renderDescriptionList, +} from "@goauthentik/components/DescriptionList"; +import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/UserEvents"; -import { AKElement, rootInterface } from "@goauthentik/elements/Base"; +import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/CodeMirror"; -import { PFColor } from "@goauthentik/elements/Label"; +import { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/PageHeader"; import { PFSize } from "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Tabs"; @@ -55,7 +61,7 @@ import { import "./UserDevicesTable"; @customElement("ak-user-view") -export class UserViewPage extends AKElement { +export class UserViewPage extends WithCapabilitiesConfig(AKElement) { @property({ type: Number }) set userId(id: number) { me().then((me) => { @@ -137,163 +143,90 @@ export class UserViewPage extends AKElement { const user = this.user; - const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - this.user.pk !== this.me?.user.pk; + // prettier-ignore + const userInfo: DescriptionPair[] = [ + [msg("Username"), user.username], + [msg("Name"), user.name], + [msg("Email"), user.email || "-"], + [msg("Last login"), user.lastLogin?.toLocaleString()], + [msg("Active"), html``], + [msg("Type"), userTypeToLabel(user.type)], + [msg("Superuser"), html``], + [msg("Actions"), this.renderActionButtons(user)], + [msg("Recovery"), this.renderRecoveryButtons(user)], + ]; return html`
${msg("User Info")}
-
-
-
-
- ${msg("Username")} -
-
-
${user.username}
-
-
-
-
- ${msg("Name")} -
-
-
${user.name}
-
-
-
-
- ${msg("Email")} -
-
-
${user.email || "-"}
-
-
-
-
- ${msg("Last login")} -
-
-
- ${user.lastLogin?.toLocaleString()} -
-
-
-
-
- ${msg("Active")} -
-
-
- -
-
-
-
-
- ${msg("Type")} -
-
-
- ${userTypeToLabel(user.type)} -
-
-
-
-
- ${msg("Superuser")} -
-
-
- -
-
-
-
-
- ${msg("Actions")} -
-
-
- - ${msg("Update")} - ${msg("Update User")} - - - - - { - return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ - id: user.pk, - patchedUserRequest: { - isActive: !user.isActive, - }, - }); - }} - > - - - ${canImpersonate - ? html` - { - return new CoreApi(DEFAULT_CONFIG) - .coreUsersImpersonateCreate({ - id: user.pk, - }) - .then(() => { - window.location.href = "/"; - }); - }} - > - - ${msg("Impersonate")} - - - ` - : nothing} -
-
-
-
-
- ${msg("Recovery")} -
-
-
+
${renderDescriptionList(userInfo)}
+ `; + } + + renderActionButtons(user: User) { + const canImpersonate = + this.can(CapabilitiesEnum.CanImpersonate) && user.pk !== this.me?.user.pk; + + return html`
+ + ${msg("Update")} + ${msg("Update User")} + + + + { + return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ + id: user.pk, + patchedUserRequest: { + isActive: !user.isActive, + }, + }); + }} + > + + + ${canImpersonate + ? html` + { + return new CoreApi(DEFAULT_CONFIG) + .coreUsersImpersonateCreate({ + id: user.pk, + }) + .then(() => { + window.location.href = "/"; + }); + }} + > + + ${msg("Impersonate")} + + + ` + : nothing} +
`; + } + + renderRecoveryButtons(user: User) { + return html`
${msg("Update password")} ${msg("Update password")} diff --git a/web/src/assets/images/flow_background.jpg b/web/src/assets/images/flow_background.jpg index 7035dab16..ae78b49e9 100644 Binary files a/web/src/assets/images/flow_background.jpg and b/web/src/assets/images/flow_background.jpg differ diff --git a/web/src/common/constants.ts b/web/src/common/constants.ts index 5910ef35e..03ee1393a 100644 --- a/web/src/common/constants.ts +++ b/web/src/common/constants.ts @@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success"; export const ERROR_CLASS = "pf-m-danger"; export const PROGRESS_CLASS = "pf-m-in-progress"; export const CURRENT_CLASS = "pf-m-current"; -export const VERSION = "2023.10.2"; +export const VERSION = "2023.10.6"; export const TITLE_DEFAULT = "authentik"; export const ROUTE_SEPARATOR = ";"; diff --git a/web/src/common/merge.ts b/web/src/common/merge.ts deleted file mode 100644 index 4e60e856c..000000000 --- a/web/src/common/merge.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** Taken from: https://github.com/zellwk/javascript/tree/master - * - * We have added some typescript annotations, but this is such a rich feature with deep nesting - * we'll just have to watch it closely for any issues. So far there don't seem to be any. - * - */ - -function objectType(value: T) { - return Object.prototype.toString.call(value); -} - -// Creates a deep clone for each value -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function cloneDescriptorValue(value: any) { - // Arrays - if (objectType(value) === "[object Array]") { - const array = []; - for (let v of value) { - v = cloneDescriptorValue(v); - array.push(v); - } - return array; - } - - // Objects - if (objectType(value) === "[object Object]") { - const obj = {}; - const props = Object.keys(value); - for (const prop of props) { - const descriptor = Object.getOwnPropertyDescriptor(value, prop); - if (!descriptor) { - continue; - } - - if (descriptor.value) { - descriptor.value = cloneDescriptorValue(descriptor.value); - } - Object.defineProperty(obj, prop, descriptor); - } - return obj; - } - - // Other Types of Objects - if (objectType(value) === "[object Date]") { - return new Date(value.getTime()); - } - - if (objectType(value) === "[object Map]") { - const map = new Map(); - for (const entry of value) { - map.set(entry[0], cloneDescriptorValue(entry[1])); - } - return map; - } - - if (objectType(value) === "[object Set]") { - const set = new Set(); - for (const entry of value.entries()) { - set.add(cloneDescriptorValue(entry[0])); - } - return set; - } - - // Types we don't need to clone or cannot clone. - // Examples: - // - Primitives don't need to clone - // - Functions cannot clone - return value; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function _merge(output: Record, input: Record) { - const props = Object.keys(input); - - for (const prop of props) { - // Prevents Prototype Pollution - if (prop === "__proto__") continue; - - const descriptor = Object.getOwnPropertyDescriptor(input, prop); - if (!descriptor) { - continue; - } - - const value = descriptor.value; - if (value) descriptor.value = cloneDescriptorValue(value); - - // If don't have prop => Define property - // [ken@goauthentik] Using `hasOwn` is preferable over - // the basic identity test, according to Typescript. - if (!Object.hasOwn(output, prop)) { - Object.defineProperty(output, prop, descriptor); - continue; - } - - // If have prop, but type is not object => Overwrite by redefining property - if (typeof output[prop] !== "object") { - Object.defineProperty(output, prop, descriptor); - continue; - } - - // If have prop, but type is Object => Concat the arrays together. - if (objectType(descriptor.value) === "[object Array]") { - output[prop] = output[prop].concat(descriptor.value); - continue; - } - - // If have prop, but type is Object => Merge. - _merge(output[prop], descriptor.value); - } -} - -export function merge(...sources: Array) { - const result = {}; - for (const source of sources) { - _merge(result, source); - } - return result; -} - -export default merge; diff --git a/web/src/common/sentry.ts b/web/src/common/sentry.ts index f89a35f13..e3dfe3c2f 100644 --- a/web/src/common/sentry.ts +++ b/web/src/common/sentry.ts @@ -5,7 +5,7 @@ import { me } from "@goauthentik/common/users"; import * as Sentry from "@sentry/browser"; import { Integrations } from "@sentry/tracing"; -import { Config, ResponseError } from "@goauthentik/api"; +import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api"; export const TAG_SENTRY_COMPONENT = "authentik.component"; export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities"; @@ -60,6 +60,11 @@ export async function configureSentry(canDoPpi = false): Promise { scope.setTransactionName(`authentik.web.if.${currentInterface()}`), ); } + if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { + const Spotlight = await import("@spotlightjs/spotlight"); + + Spotlight.init({ injectImmediately: true }); + } if (cfg.errorReporting.sendPii && canDoPpi) { me().then((user) => { Sentry.setUser({ email: user.user.email }); diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css index a46ec8eb1..a9a085e54 100644 --- a/web/src/common/styles/authentik.css +++ b/web/src/common/styles/authentik.css @@ -12,6 +12,9 @@ /* PatternFly likes to override global variables for some reason */ --ak-global--Color--100: var(--pf-global--Color--100); + + /* Minimum width after which the sidebar becomes automatic */ + --ak-sidebar--minimum-auto-width: 80rem; } ::-webkit-scrollbar { @@ -121,3 +124,27 @@ html > form > input { margin-right: 6px; margin-bottom: 6px; } + +/* Flow-card adjustments for static pages */ +.pf-c-brand { + padding-top: calc( + var(--pf-c-login__main-footer-links--PaddingTop) + + var(--pf-c-login__main-footer-links--PaddingBottom) + + var(--pf-c-login__main-body--PaddingBottom) + ); + max-height: 9rem; +} +.ak-brand { + display: flex; + justify-content: center; +} +.ak-brand img { + padding: 0 2rem; + max-height: inherit; +} + +@media (min-height: 60rem) { + .pf-c-login.stacked .pf-c-login__main { + margin-top: 13rem; + } +} diff --git a/web/src/common/styles/theme-dark.css b/web/src/common/styles/theme-dark.css index 30cda084a..b36ddc735 100644 --- a/web/src/common/styles/theme-dark.css +++ b/web/src/common/styles/theme-dark.css @@ -257,7 +257,8 @@ select[multiple] option:checked { .pf-c-login__main-header-desc { color: var(--ak-dark-foreground); } -.pf-c-login__main-footer-links-item img { +.pf-c-login__main-footer-links-item img, +.pf-c-login__main-footer-links-item .fas { filter: invert(1); } .pf-c-login__main-footer-band { @@ -310,6 +311,12 @@ select[multiple] option:checked { --pf-c-wizard__nav-link--before--BackgroundColor: transparent; } /* tree view */ +.pf-c-tree-view__node { + --pf-c-tree-view__node--Color: var(--ak-dark-foreground); +} +.pf-c-tree-view__node-toggle { + --pf-c-tree-view__node-toggle--Color: var(--ak-dark-foreground); +} .pf-c-tree-view__node:focus { --pf-c-tree-view__node--focus--BackgroundColor: var(--ak-dark-background-light-ish); } diff --git a/web/src/common/utils.ts b/web/src/common/utils.ts index 1ae395b4e..2b88f43dd 100644 --- a/web/src/common/utils.ts +++ b/web/src/common/utils.ts @@ -54,6 +54,13 @@ export function camelToSnake(key: string): string { return result.split(" ").join("_").toLowerCase(); } +const capitalize = (key: string) => (key.length === 0 ? "" : key[0].toUpperCase() + key.slice(1)); + +export function snakeToCamel(key: string) { + const [start, ...rest] = key.split("_"); + return [start, ...rest.map(capitalize)].join(""); +} + export function groupBy(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> { const m = new Map(); objects.forEach((obj) => { diff --git a/web/src/components/DescriptionList.ts b/web/src/components/DescriptionList.ts new file mode 100644 index 000000000..cfeee4640 --- /dev/null +++ b/web/src/components/DescriptionList.ts @@ -0,0 +1,94 @@ +import { TemplateResult, html, nothing } from "lit"; +import { classMap } from "lit/directives/class-map.js"; +import { map } from "lit/directives/map.js"; + +export type DescriptionDesc = string | TemplateResult | undefined | typeof nothing; +export type DescriptionPair = [string, DescriptionDesc]; +export type DescriptionRecord = { term: string; desc: DescriptionDesc }; + +interface DescriptionConfig { + horizontal: boolean; + compact: boolean; + twocolumn: boolean; + threecolumn: boolean; +} + +const isDescriptionRecordCollection = (v: Array): v is DescriptionRecord[] => + v.length > 0 && typeof v[0] === "object" && !Array.isArray(v[0]); + +function renderDescriptionGroup([term, description]: DescriptionPair) { + return html`
+
+ ${term} +
+
+
${description ?? nothing}
+
+
`; +} + +function recordToPair({ term, desc }: DescriptionRecord): DescriptionPair { + return [term, desc]; +} + +function alignTermType(terms: DescriptionRecord[] | DescriptionPair[] = []) { + if (isDescriptionRecordCollection(terms)) { + return terms.map(recordToPair); + } + return terms ?? []; +} + +/** + * renderDescriptionList + * + * This function renders the most common form of the PatternFly description list used in our code. + * It expects either an array of term/description pairs or an array of `{ term: string, description: + * string | TemplateResult }`. + * + * An optional dictionary of configuration options is available. These enable the Patternfly + * "horizontal," "compact", "2 column on large," or "3 column on large" layouts that are (so far) + * the layouts used in Authentik's (and Gravity's, for that matter) code. + * + * This is not a web component and it does not bring its own styling ; calling code will still have + * to provide the styling necessary. It is only a function to replace the repetitious boilerplate of + * routine description lists. Its output is a standard TemplateResult that will be fully realized + * within the context of the DOM or ShadowDOM in which it is called. + */ + +const defaultConfig = { + horizontal: false, + compact: false, + twocolumn: false, + threecolumn: false, +}; + +export function renderDescriptionList( + terms: DescriptionRecord[], + config?: DescriptionConfig, +): TemplateResult; + +export function renderDescriptionList( + terms: DescriptionPair[], + config?: DescriptionConfig, +): TemplateResult; + +export function renderDescriptionList( + terms: DescriptionRecord[] | DescriptionPair[] = [], + config: DescriptionConfig = defaultConfig, +) { + const checkedTerms = alignTermType(terms); + const classes = classMap({ + "pf-m-horizontal": config.horizontal, + "pf-m-compact": config.compact, + "pf-m-2-col-on-lg": config.twocolumn, + "pf-m-3-col-on-lg": config.threecolumn, + }); + + return html` +
+ ${map(checkedTerms, renderDescriptionGroup)} +
+ `; +} + +export default renderDescriptionList; diff --git a/web/src/components/HorizontalLightComponent.ts b/web/src/components/HorizontalLightComponent.ts new file mode 100644 index 000000000..7d34c833d --- /dev/null +++ b/web/src/components/HorizontalLightComponent.ts @@ -0,0 +1,72 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; + +type HelpType = TemplateResult | typeof nothing; + +export class HorizontalLightComponent extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @property({ type: Object }) + bighelp?: TemplateResult | TemplateResult[]; + + @property({ type: Boolean }) + hidden = false; + + @property({ type: Boolean }) + invalid = false; + + @property({ attribute: false }) + errorMessages: string[] = []; + + renderControl() { + throw new Error("Must be implemented in a subclass"); + } + + renderHelp(): HelpType[] { + const bigHelp: HelpType[] = Array.isArray(this.bighelp) + ? this.bighelp + : [this.bighelp ?? nothing]; + return [ + this.help ? html`

${this.help}

` : nothing, + ...bigHelp, + ]; + } + + render() { + // prettier-ignore + return html` + ${this.renderControl()} + ${this.renderHelp()} + `; + } +} diff --git a/web/src/components/ak-event-info.ts b/web/src/components/ak-event-info.ts index 6901f31a8..e728958c2 100644 --- a/web/src/components/ak-event-info.ts +++ b/web/src/components/ak-event-info.ts @@ -285,10 +285,12 @@ export class EventInfo extends AKElement { } renderEmailSent() { + let body = this.event.context.body as string; + body = body.replace("cid:logo.png", "/static/dist/assets/icons/icon_left_brand.png"); return html`
${msg("Email info:")}
${this.getEmailInfo(this.event.context)}
- + `; } diff --git a/web/src/components/ak-multi-select.ts b/web/src/components/ak-multi-select.ts new file mode 100644 index 000000000..9efddd079 --- /dev/null +++ b/web/src/components/ak-multi-select.ts @@ -0,0 +1,150 @@ +import "@goauthentik/app/elements/forms/HorizontalFormElement"; +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { map } from "lit/directives/map.js"; +import { Ref, createRef, ref } from "lit/directives/ref.js"; + +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +type Pair = [string, string]; + +const selectStyles = css` + select[multiple] { + min-height: 15rem; + } +`; + +/** + * Horizontal layout control with a multi-select. + * + * @part select - The select itself, to override the height specified above. + */ +@customElement("ak-multi-select") +export class AkMultiSelect extends AKElement { + constructor() { + super(); + this.dataset.akControl = "true"; + } + + static get styles() { + return [PFBase, PFForm, PFFormControl, selectStyles]; + } + + /** + * The [name] attribute, which is also distributed to the layout manager and the input control. + */ + @property({ type: String }) + name!: string; + + /** + * The text label to display on the control + */ + @property({ type: String }) + label = ""; + + /** + * The values to be displayed in the select. The format is [Value, Label], where the label is + * what will be displayed. + */ + @property({ attribute: false }) + options: Pair[] = []; + + /** + * If true, at least one object must be selected + */ + @property({ type: Boolean }) + required = false; + + /** + * Supporting a simple help string + */ + @property({ type: String }) + help = ""; + + /** + * For more complex help instructions, provide a template result. + */ + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + /** + * An array of strings representing the objects currently selected. + */ + @property({ type: Array }) + values: string[] = []; + + /** + * Helper accessor for older code + */ + get value() { + return this.values; + } + + /** + * One of two criteria (the other being the data-ak-control flag) that specifies this as a + * control that produces values of specific interest to our REST API. This is our modern + * accessor name. + */ + json() { + return this.values; + } + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + + handleChange(ev: Event) { + if (ev.type === "change") { + this.values = Array.from(this.selectRef.value!.querySelectorAll("option")) + .filter((option) => option.selected) + .map((option) => option.value); + this.dispatchEvent( + new CustomEvent("ak-select", { + detail: this.values, + composed: true, + bubbles: true, + }), + ); + } + } + + selectRef: Ref = createRef(); + + render() { + return html`
+ + + ${this.renderHelp()} + +
`; + } +} + +export default AkMultiSelect; diff --git a/web/src/components/ak-number-input.ts b/web/src/components/ak-number-input.ts index dcfef1541..65fc10b0e 100644 --- a/web/src/components/ak-number-input.ts +++ b/web/src/components/ak-number-input.ts @@ -1,51 +1,21 @@ -import { AKElement } from "@goauthentik/elements/Base"; - -import { html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @customElement("ak-number-input") -export class AkNumberInput extends AKElement { - // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but - // we're not actually using that and, for the meantime, we need the form handlers to be able to - // find the children of this component. - // - // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the - // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in - // general. - protected createRenderRoot() { - return this; - } - - @property({ type: String }) - name!: string; - - @property({ type: String }) - label = ""; - +export class AkNumberInput extends HorizontalLightComponent { @property({ type: Number, reflect: true }) value = 0; - @property({ type: Boolean }) - required = false; - - @property({ type: String }) - help = ""; - - render() { - return html` - - ${this.help ? html`

${this.help}

` : nothing} -
`; + />`; } } diff --git a/web/src/components/ak-radio-input.ts b/web/src/components/ak-radio-input.ts index c65b8f1ae..b4899cfc5 100644 --- a/web/src/components/ak-radio-input.ts +++ b/web/src/components/ak-radio-input.ts @@ -1,35 +1,13 @@ -import { AKElement } from "@goauthentik/elements/Base"; import { RadioOption } from "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/Radio"; import { html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @customElement("ak-radio-input") -export class AkRadioInput extends AKElement { - // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but - // we're not actually using that and, for the meantime, we need the form handlers to be able to - // find the children of this component. - // - // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the - // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in - // general. - protected createRenderRoot() { - return this; - } - - @property({ type: String }) - name!: string; - - @property({ type: String }) - label = ""; - - @property({ type: String }) - help = ""; - - @property({ type: Boolean }) - required = false; - +export class AkRadioInput extends HorizontalLightComponent { @property({ type: Object }) value!: T; @@ -37,24 +15,25 @@ export class AkRadioInput extends AKElement { options: RadioOption[] = []; handleInput(ev: CustomEvent) { - this.value = ev.detail.value; + if ("detail" in ev) { + this.value = ev.detail.value; + } } - render() { - return html` - ${this.help.trim() ? html`

${this.help}

` - : nothing} -
`; + : nothing}`; } } diff --git a/web/src/components/ak-slug-input.ts b/web/src/components/ak-slug-input.ts index b4fac3380..161a00c87 100644 --- a/web/src/components/ak-slug-input.ts +++ b/web/src/components/ak-slug-input.ts @@ -1,44 +1,16 @@ import { convertToSlug } from "@goauthentik/common/utils"; -import { AKElement } from "@goauthentik/elements/Base"; -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property, query } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @customElement("ak-slug-input") -export class AkSlugInput extends AKElement { - // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but - // we're not actually using that and, for the meantime, we need the form handlers to be able to - // find the children of this component. - // - // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the - // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in - // general. - protected createRenderRoot() { - return this; - } - - @property({ type: String }) - name!: string; - - @property({ type: String }) - label = ""; - +export class AkSlugInput extends HorizontalLightComponent { @property({ type: String, reflect: true }) value = ""; - @property({ type: Boolean }) - required = false; - - @property({ type: String }) - help = ""; - - @property({ type: Boolean }) - hidden = false; - - @property({ type: Object }) - bighelp!: TemplateResult | TemplateResult[]; - @property({ type: String }) source = ""; @@ -59,13 +31,6 @@ export class AkSlugInput extends AKElement { this.input.addEventListener("input", this.handleTouch); } - renderHelp() { - return [ - this.help ? html`

${this.help}

` : nothing, - this.bighelp ? this.bighelp : nothing, - ]; - } - // Do not stop propagation of this event; it must be sent up the tree so that a parent // component, such as a custom forms manager, may receive it. handleTouch(ev: Event) { @@ -150,21 +115,13 @@ export class AkSlugInput extends AKElement { super.disconnectedCallback(); } - render() { - return html` - - ${this.renderHelp()} - `; + />`; } } diff --git a/web/src/components/ak-status-label.ts b/web/src/components/ak-status-label.ts new file mode 100644 index 000000000..f2ff005bf --- /dev/null +++ b/web/src/components/ak-status-label.ts @@ -0,0 +1,116 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { msg } from "@lit/localize"; +import { css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +import PFLabel from "@patternfly/patternfly/components/Label/label.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +const statusNames = ["error", "warning", "info"] as const; +type StatusName = (typeof statusNames)[number]; + +const statusToDetails = new Map([ + ["error", ["pf-m-red", "fa-times"]], + ["warning", ["pf-m-orange", "fa-exclamation-triangle"]], + ["info", ["pf-m-gray", "fa-info-circle"]], +]); + +const styles = css` + :host { + --pf-c-label--m-gray--BackgroundColor: var(--pf-global--palette--black-100); + --pf-c-label--m-gray__icon--Color: var(--pf-global--primary-color--100); + --pf-c-label--m-gray__content--Color: var(--pf-global--info-color--200); + --pf-c-label--m-gray__content--before--BorderColor: var(--pf-global--palette--black-400); + --pf-c-label--m-gray__content--link--hover--before--BorderColor: var( + --pf-global--primary-color--100 + ); + --pf-c-label--m-gray__content--link--focus--before--BorderColor: var( + --pf-global--primary-color--100 + ); + } + + .pf-c-label.pf-m-gray { + --pf-c-label--BackgroundColor: var(--pf-c-label--m-gray--BackgroundColor); + --pf-c-label__icon--Color: var(--pf-c-label--m-gray__icon--Color); + --pf-c-label__content--Color: var(--pf-c-label--m-gray__content--Color); + --pf-c-label__content--before--BorderColor: var( + --pf-c-label--m-gray__content--before--BorderColor + ); + --pf-c-label__content--link--hover--before--BorderColor: var( + --pf-c-label--m-gray__content--link--hover--before--BorderColor + ); + --pf-c-label__content--link--focus--before--BorderColor: var( + --pf-c-label--m-gray__content--link--focus--before--BorderColor + ); + } +`; + +/** + * A boolean status indicator + * + * Based on the Patternfly "label" pattern, this component exists to display "Yes" or "No", but this + * is configurable. + * + * When the boolean attribute `good` is present, the background will be green and the icon will be a + * ✓. If the `good` attribute is not present, the background will be a warning color and an + * alternative symbol. Which color and symbol depends on the `type` of the negative status we want + * to show: + * + * - type="error" (default): A Red ✖ + * - type="warning" An orange ⚠ + * - type="info" A grey ⓘ + * + * By default, the messages for "good" and "other" are "Yes" and "No" respectively, but these can be + * customized with the attributes `good-label` and `bad-label`. + */ + +@customElement("ak-status-label") +export class AkStatusLabel extends AKElement { + static get styles() { + return [PFBase, PFLabel, styles]; + } + + @property({ type: Boolean }) + good = false; + + @property({ type: String, attribute: "good-label" }) + goodLabel = msg("Yes"); + + @property({ type: String, attribute: "bad-label" }) + badLabel = msg("No"); + + @property({ type: Boolean }) + compact = false; + + @property({ type: String }) + type: StatusName = "error"; + + render() { + const details = statusToDetails.get(this.type); + if (!details) { + throw new Error(`Bad status type [${this.type}] passed to ak-status-label`); + } + + const [label, color, icon] = this.good + ? [this.goodLabel, "pf-m-green", "fa-check"] + : [this.badLabel, ...details]; + + const classes = { + "pf-c-label": true, + [color]: true, + "pf-m-compact": this.compact, + }; + + return html` + + + ${label} + + `; + } +} + +export default AkStatusLabel; diff --git a/web/src/components/ak-text-input.ts b/web/src/components/ak-text-input.ts index 2e7a9dd63..545ff9018 100644 --- a/web/src/components/ak-text-input.ts +++ b/web/src/components/ak-text-input.ts @@ -1,65 +1,21 @@ -import { AKElement } from "@goauthentik/elements/Base"; - -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @customElement("ak-text-input") -export class AkTextInput extends AKElement { - // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but - // we're not actually using that and, for the meantime, we need the form handlers to be able to - // find the children of this component. - // - // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the - // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in - // general. - protected createRenderRoot() { - return this; - } - - @property({ type: String }) - name!: string; - - @property({ type: String }) - label = ""; - +export class AkTextInput extends HorizontalLightComponent { @property({ type: String, reflect: true }) value = ""; - @property({ type: Boolean }) - required = false; - - @property({ type: String }) - help = ""; - - @property({ type: Boolean }) - hidden = false; - - @property({ type: Object }) - bighelp!: TemplateResult | TemplateResult[]; - - renderHelp() { - return [ - this.help ? html`

${this.help}

` : nothing, - this.bighelp ? this.bighelp : nothing, - ]; - } - - render() { - return html` - - ${this.renderHelp()} - `; + />`; } } diff --git a/web/src/components/ak-textarea-input.ts b/web/src/components/ak-textarea-input.ts index 95b138550..9ca2efc4f 100644 --- a/web/src/components/ak-textarea-input.ts +++ b/web/src/components/ak-textarea-input.ts @@ -1,57 +1,22 @@ -import { AKElement } from "@goauthentik/elements/Base"; - -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @customElement("ak-textarea-input") -export class AkTextareaInput extends AKElement { - // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but - // we're not actually using that and, for the meantime, we need the form handlers to be able to - // find the children of this component. - // - // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the - // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in - // general. - protected createRenderRoot() { - return this; - } - - @property({ type: String }) - name!: string; - - @property({ type: String }) - label = ""; - - @property({ type: String }) +export class AkTextareaInput extends HorizontalLightComponent { + @property({ type: String, reflect: true }) value = ""; - @property({ type: Boolean }) - required = false; - - @property({ type: String }) - help = ""; - - @property({ type: Object }) - bighelp!: TemplateResult | TemplateResult[]; - - renderHelp() { - return [ - this.help ? html`

${this.help}

` : nothing, - this.bighelp ? this.bighelp : nothing, - ]; - } - - render() { - return html` - - ${this.renderHelp()} - `; + >${this.value !== undefined ? this.value : ""} `; } } diff --git a/web/src/components/events/ObjectChangelog.ts b/web/src/components/events/ObjectChangelog.ts index 160a98d73..dcfef105b 100644 --- a/web/src/components/events/ObjectChangelog.ts +++ b/web/src/components/events/ObjectChangelog.ts @@ -1,3 +1,5 @@ +import { EventGeo } from "@goauthentik/app/admin/events/utils"; +import { actionToLabel } from "@goauthentik/app/common/labels"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EventWithContext } from "@goauthentik/common/events"; import { uiConfig } from "@goauthentik/common/ui/config"; @@ -73,7 +75,7 @@ export class ObjectChangelog extends Table { row(item: EventWithContext): TemplateResult[] { return [ - html`${item.action}`, + html`${actionToLabel(item.action)}`, html`
${item.user?.username}
${item.user.on_behalf_of ? html` @@ -81,7 +83,9 @@ export class ObjectChangelog extends Table { ` : html``}`, html`${item.created?.toLocaleString()}`, - html`${item.clientIp || msg("-")}`, + html`
${item.clientIp || msg("-")}
+ + ${EventGeo(item)}`, ]; } diff --git a/web/src/components/stories/ak-multi-select.stories.ts b/web/src/components/stories/ak-multi-select.stories.ts new file mode 100644 index 000000000..a3115c560 --- /dev/null +++ b/web/src/components/stories/ak-multi-select.stories.ts @@ -0,0 +1,79 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html, render } from "lit"; + +import "../ak-multi-select"; +import AkMultiSelect from "../ak-multi-select"; + +const metadata: Meta = { + title: "Components / MultiSelect", + component: "ak-multi-select", + parameters: { + docs: { + description: { + component: "A stylized value control for multi-select displays", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} + +
+
`; + +const testOptions = [ + ["funky", "Option One: Funky"], + ["strange", "Option Two: Strange"], + ["weird", "Option Three: Weird"], +]; + +export const RadioInput = () => { + const result = ""; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + const messagePad = document.getElementById("message-pad"); + const component: AkMultiSelect | null = document.querySelector( + 'ak-multi-select[name="ak-test-multi-select"]', + ); + + const results = html` +

Results from event:

+
    + ${ev.target.value.map((v: string) => html`
  • ${v}
  • `)} +
+

Results from component:

+
    + ${component!.json().map((v: string) => html`
  • ${v}
  • `)} +
+ `; + + render(results, messagePad!); + }; + + return container( + html` +
${result}
`, + ); +}; diff --git a/web/src/components/stories/ak-number-input.stories.ts b/web/src/components/stories/ak-number-input.stories.ts index 642a543a8..e1c991fa7 100644 --- a/web/src/components/stories/ak-number-input.stories.ts +++ b/web/src/components/stories/ak-number-input.stories.ts @@ -39,9 +39,8 @@ const container = (testItem: TemplateResult) => export const NumberInput = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const displayChange = (ev: any) => { - document.getElementById( - "number-message-pad", - )!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; + document.getElementById("number-message-pad")!.innerText = + `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; }; return container( diff --git a/web/src/components/stories/ak-status-label.stories.ts b/web/src/components/stories/ak-status-label.stories.ts new file mode 100644 index 000000000..ab525a20e --- /dev/null +++ b/web/src/components/stories/ak-status-label.stories.ts @@ -0,0 +1,101 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-status-label"; +import AkStatusLabel from "../ak-status-label"; + +const metadata: Meta = { + title: "Components / App Status Label", + component: "ak-status-label", + parameters: { + docs: { + description: { + component: "A status label display", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + ${testItem} +
`; + +export const AppIcon = () => { + // prettier-ignore + return container(html` +
+
Good
+ + + +
+
Bad (Default)
+ + + +
+
Programmatically Good
+ + + +
+
Programmatically Bad
+ + + +
+
Good Warning
+ + + +
+
Bad Warning
+ + + +
+
Good Info
+ + + +
+
Bad Info
+ + + +
+
Good With Alternative Message
+ + + +
+
Bad with Alternative Message
+ + + +
+
Good, Compact
+ + + +
+
Bad, Compact
+ + + +
+
+ `); +}; diff --git a/web/src/components/stories/ak-switch-input.stories.ts b/web/src/components/stories/ak-switch-input.stories.ts index 530e8c368..4985d7891 100644 --- a/web/src/components/stories/ak-switch-input.stories.ts +++ b/web/src/components/stories/ak-switch-input.stories.ts @@ -46,9 +46,8 @@ export const SwitchInput = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const displayChange = (ev: any) => { - document.getElementById( - "switch-message-pad", - )!.innerText = `Value selected: ${JSON.stringify(ev.target.checked, null, 2)}`; + document.getElementById("switch-message-pad")!.innerText = + `Value selected: ${JSON.stringify(ev.target.checked, null, 2)}`; }; return container( diff --git a/web/src/components/stories/ak-textarea-input.stories.ts b/web/src/components/stories/ak-textarea-input.stories.ts index cab3c47ff..9afb1dd9e 100644 --- a/web/src/components/stories/ak-textarea-input.stories.ts +++ b/web/src/components/stories/ak-textarea-input.stories.ts @@ -39,9 +39,8 @@ const container = (testItem: TemplateResult) => export const TextareaInput = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const displayChange = (ev: any) => { - document.getElementById( - "textarea-message-pad", - )!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; + document.getElementById("textarea-message-pad")!.innerText = + `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`; }; return container( diff --git a/web/src/components/stories/ak-toggle-group.stories.ts b/web/src/components/stories/ak-toggle-group.stories.ts index 31b2e6c7f..ed35571f6 100644 --- a/web/src/components/stories/ak-toggle-group.stories.ts +++ b/web/src/components/stories/ak-toggle-group.stories.ts @@ -54,9 +54,8 @@ const testOptions = [ export const ToggleGroup = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const displayChange = (ev: any) => { - document.getElementById( - "toggle-message-pad", - )!.innerText = `Value selected: ${ev.detail.value}`; + document.getElementById("toggle-message-pad")!.innerText = + `Value selected: ${ev.detail.value}`; }; return container( diff --git a/web/src/elements/AuthentikContexts.ts b/web/src/elements/AuthentikContexts.ts new file mode 100644 index 000000000..02fa89316 --- /dev/null +++ b/web/src/elements/AuthentikContexts.ts @@ -0,0 +1,11 @@ +import { createContext } from "@lit-labs/context"; + +import type { Config, CurrentTenant } from "@goauthentik/api"; + +export const authentikConfigContext = createContext(Symbol("authentik-config-context")); + +export const authentikTenantContext = createContext( + Symbol("authentik-tenant-context"), +); + +export default authentikConfigContext; diff --git a/web/src/elements/Base.ts b/web/src/elements/Base.ts index 7b2420454..09a2d2858 100644 --- a/web/src/elements/Base.ts +++ b/web/src/elements/Base.ts @@ -1,18 +1,18 @@ -import { config, tenant } from "@goauthentik/common/api/config"; import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; -import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; +import { UIConfig } from "@goauthentik/common/ui/config"; import { adaptCSS } from "@goauthentik/common/utils"; +import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; import { localized } from "@lit/localize"; -import { CSSResult, LitElement } from "lit"; -import { state } from "lit/decorators.js"; +import { LitElement } from "lit"; import AKGlobal from "@goauthentik/common/styles/authentik.css"; import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api"; +import { AdoptedStyleSheetsElement } from "./types"; + type AkInterface = HTMLElement & { getTheme: () => Promise; tenant?: CurrentTenant; @@ -23,13 +23,6 @@ type AkInterface = HTMLElement & { export const rootInterface = (): T | undefined => (document.body.querySelector("[data-ak-interface-root]") as T) ?? undefined; -export function ensureCSSStyleSheet(css: CSSStyleSheet | CSSResult): CSSStyleSheet { - if (css instanceof CSSResult) { - return css.styleSheet!; - } - return css; -} - let css: Promise | undefined; function fetchCustomCSS(): Promise { if (!css) { @@ -50,10 +43,6 @@ function fetchCustomCSS(): Promise { return css; } -export interface AdoptedStyleSheetsElement { - adoptedStyleSheets: readonly CSSStyleSheet[]; -} - const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)"; @localized() @@ -173,34 +162,3 @@ export class AKElement extends LitElement { this.requestUpdate(); } } - -export class Interface extends AKElement implements AkInterface { - @state() - tenant?: CurrentTenant; - - @state() - uiConfig?: UIConfig; - - @state() - config?: Config; - - constructor() { - super(); - document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; - tenant().then((tenant) => (this.tenant = tenant)); - config().then((config) => (this.config = config)); - this.dataset.akInterfaceRoot = "true"; - } - - _activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum): void { - super._activateTheme(root, theme); - super._activateTheme(document, theme); - } - - async getTheme(): Promise { - if (!this.uiConfig) { - this.uiConfig = await uiConfig(); - } - return this.uiConfig.theme?.base || UiThemeEnum.Automatic; - } -} diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts new file mode 100644 index 000000000..b2470cfd2 --- /dev/null +++ b/web/src/elements/Interface/Interface.ts @@ -0,0 +1,85 @@ +import { config, tenant } from "@goauthentik/common/api/config"; +import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; +import { + authentikConfigContext, + authentikTenantContext, +} from "@goauthentik/elements/AuthentikContexts"; +import type { AdoptedStyleSheetsElement } from "@goauthentik/elements/types"; +import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; + +import { ContextProvider } from "@lit-labs/context"; +import { state } from "lit/decorators.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api"; + +import { AKElement } from "../Base"; + +type AkInterface = HTMLElement & { + getTheme: () => Promise; + tenant?: CurrentTenant; + uiConfig?: UIConfig; + config?: Config; +}; + +export class Interface extends AKElement implements AkInterface { + @state() + uiConfig?: UIConfig; + + _configContext = new ContextProvider(this, { + context: authentikConfigContext, + initialValue: undefined, + }); + + _config?: Config; + + @state() + set config(c: Config) { + this._config = c; + this._configContext.setValue(c); + this.requestUpdate(); + } + + get config(): Config | undefined { + return this._config; + } + + _tenantContext = new ContextProvider(this, { + context: authentikTenantContext, + initialValue: undefined, + }); + + _tenant?: CurrentTenant; + + @state() + set tenant(c: CurrentTenant) { + this._tenant = c; + this._tenantContext.setValue(c); + this.requestUpdate(); + } + + get tenant(): CurrentTenant | undefined { + return this._tenant; + } + + constructor() { + super(); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; + tenant().then((tenant) => (this.tenant = tenant)); + config().then((config) => (this.config = config)); + this.dataset.akInterfaceRoot = "true"; + } + + _activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum): void { + super._activateTheme(root, theme); + super._activateTheme(document, theme); + } + + async getTheme(): Promise { + if (!this.uiConfig) { + this.uiConfig = await uiConfig(); + } + return this.uiConfig.theme?.base || UiThemeEnum.Automatic; + } +} diff --git a/web/src/elements/Interface/authentikConfigProvider.ts b/web/src/elements/Interface/authentikConfigProvider.ts new file mode 100644 index 000000000..5b2027fd0 --- /dev/null +++ b/web/src/elements/Interface/authentikConfigProvider.ts @@ -0,0 +1,20 @@ +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; + +import { consume } from "@lit-labs/context"; +import type { LitElement } from "lit"; + +import type { Config } from "@goauthentik/api"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = new (...args: any[]) => T; + +export function WithAuthentikConfig>( + superclass: T, + subscribe = true, +) { + abstract class WithAkConfigProvider extends superclass { + @consume({ context: authentikConfigContext, subscribe }) + public authentikConfig!: Config; + } + return WithAkConfigProvider; +} diff --git a/web/src/elements/Interface/capabilitiesProvider.ts b/web/src/elements/Interface/capabilitiesProvider.ts new file mode 100644 index 000000000..402653880 --- /dev/null +++ b/web/src/elements/Interface/capabilitiesProvider.ts @@ -0,0 +1,69 @@ +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; + +import { consume } from "@lit-labs/context"; +import type { LitElement } from "lit"; + +import { CapabilitiesEnum } from "@goauthentik/api"; +import { Config } from "@goauthentik/api"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = abstract new (...args: any[]) => T; + +// Using a unique, lexically scoped, and locally static symbol as the field name for the context +// means that it's inaccessible to any child class looking for it. It's one of the strongest privacy +// guarantees in JavaScript. + +class WCC { + public static readonly capabilitiesConfig: unique symbol = Symbol(); +} + +/** + * withCapabilitiesContext mixes in a single method to any LitElement, `can()`, which takes a + * CapabilitiesEnum and returns true or false. + * + * Usage: + * + * After importing, simply mixin this function: + * + * ``` + * export class AkMyNiftyNewFeature extends withCapabilitiesContext(AKElement) { + * ``` + * + * And then if you need to check on a capability: + * + * ``` + * if (this.can(CapabilitiesEnum.IsEnterprise) { ... } + * ``` + * + * This code re-exports CapabilitiesEnum, so you won't have to import it on a separate line if you + * don't need anything else from the API. + * + * Passing `true` as the second mixin argument will cause the inheriting class to subscribe to the + * configuration context. Should the context be explicitly reset, all active web components that are + * currently active and subscribed to the context will automatically have a `requestUpdate()` + * triggered with the new configuration. + * + */ + +export function WithCapabilitiesConfig>( + superclass: T, + subscribe = true, +) { + abstract class CapabilitiesContext extends superclass { + @consume({ context: authentikConfigContext, subscribe }) + private [WCC.capabilitiesConfig]!: Config; + + can(c: CapabilitiesEnum) { + if (!this[WCC.capabilitiesConfig]) { + throw new Error( + "ConfigContext: Attempted to access site configuration before initialization.", + ); + } + return this[WCC.capabilitiesConfig].capabilities.includes(c); + } + } + + return CapabilitiesContext; +} + +export { CapabilitiesEnum }; diff --git a/web/src/elements/Interface/index.ts b/web/src/elements/Interface/index.ts new file mode 100644 index 000000000..e7d946cf6 --- /dev/null +++ b/web/src/elements/Interface/index.ts @@ -0,0 +1,4 @@ +import { Interface } from "./Interface"; + +export { Interface }; +export default Interface; diff --git a/web/src/elements/Interface/tenantProvider.ts b/web/src/elements/Interface/tenantProvider.ts new file mode 100644 index 000000000..63d389048 --- /dev/null +++ b/web/src/elements/Interface/tenantProvider.ts @@ -0,0 +1,20 @@ +import { authentikTenantContext } from "@goauthentik/elements/AuthentikContexts"; + +import { consume } from "@lit-labs/context"; +import type { LitElement } from "lit"; + +import type { CurrentTenant } from "@goauthentik/api"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = abstract new (...args: any[]) => T; + +export function WithTenantConfig>( + superclass: T, + subscribe = true, +) { + abstract class WithTenantProvider extends superclass { + @consume({ context: authentikTenantContext, subscribe }) + public tenant!: CurrentTenant; + } + return WithTenantProvider; +} diff --git a/web/src/elements/LoadingOverlay.ts b/web/src/elements/LoadingOverlay.ts index 25ed89667..8420156df 100644 --- a/web/src/elements/LoadingOverlay.ts +++ b/web/src/elements/LoadingOverlay.ts @@ -1,5 +1,5 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { PFSize } from "@goauthentik/elements/Spinner"; +import "@goauthentik/elements/EmptyState"; import { CSSResult, TemplateResult, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -33,6 +33,8 @@ export class LoadingOverlay extends AKElement { } render(): TemplateResult { - return html``; + return html` + + `; } } diff --git a/web/src/elements/PageHeader.ts b/web/src/elements/PageHeader.ts index 3f187291b..7be55996d 100644 --- a/web/src/elements/PageHeader.ts +++ b/web/src/elements/PageHeader.ts @@ -8,7 +8,8 @@ import { } from "@goauthentik/common/constants"; import { currentInterface } from "@goauthentik/common/sentry"; import { me } from "@goauthentik/common/users"; -import { AKElement, rootInterface } from "@goauthentik/elements/Base"; +import { AKElement } from "@goauthentik/elements/Base"; +import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import { msg } from "@lit/localize"; @@ -23,7 +24,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { EventsApi } from "@goauthentik/api"; @customElement("ak-page-header") -export class PageHeader extends AKElement { +export class PageHeader extends WithTenantConfig(AKElement) { @property() icon?: string; @@ -35,9 +36,8 @@ export class PageHeader extends AKElement { @property() set header(value: string) { - const tenant = rootInterface()?.tenant; const currentIf = currentInterface(); - let title = tenant?.brandingTitle || TITLE_DEFAULT; + let title = this.tenant?.brandingTitle || TITLE_DEFAULT; if (currentIf === "admin") { title = `${msg("Admin")} - ${title}`; } @@ -79,6 +79,7 @@ export class PageHeader extends AKElement { } .pf-c-page__main-section { flex-grow: 1; + flex-shrink: 1; display: flex; flex-direction: column; justify-content: center; diff --git a/web/src/elements/ak-locale-context/definitions.ts b/web/src/elements/ak-locale-context/definitions.ts index e920e85b1..018c9e2a1 100644 --- a/web/src/elements/ak-locale-context/definitions.ts +++ b/web/src/elements/ak-locale-context/definitions.ts @@ -46,6 +46,8 @@ const LOCALE_TABLE: LocaleRow[] = [ ["es", /^es([_-]|$)/i, () => msg("Spanish"), async () => await import("@goauthentik/locales/es")], ["de", /^de([_-]|$)/i, () => msg("German"), async () => await import("@goauthentik/locales/de")], ["fr", /^fr([_-]|$)/i, () => msg("French"), async () => await import("@goauthentik/locales/fr")], + ["ko", /^ko([_-]|$)/i, () => msg("Korean"), async () => await import("@goauthentik/locales/ko")], + ["nl", /^nl([_-]|$)/i, () => msg("Dutch"), async () => await import("@goauthentik/locales/nl")], ["pl", /^pl([_-]|$)/i, () => msg("Polish"), async () => await import("@goauthentik/locales/pl")], ["tr", /^tr([_-]|$)/i, () => msg("Turkish"), async () => await import("@goauthentik/locales/tr")], ["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), async () => await import("@goauthentik/locales/zh-Hant")], diff --git a/web/src/elements/buttons/SpinnerButton/BaseTaskButton.ts b/web/src/elements/buttons/SpinnerButton/BaseTaskButton.ts index 0b59a7937..653055ce4 100644 --- a/web/src/elements/buttons/SpinnerButton/BaseTaskButton.ts +++ b/web/src/elements/buttons/SpinnerButton/BaseTaskButton.ts @@ -5,6 +5,7 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { Task, TaskStatus } from "@lit-labs/task"; import { css, html } from "lit"; +import { property } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css"; @@ -57,6 +58,9 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) { actionTask: Task; + @property({ type: Boolean }) + disabled = false; + constructor() { super(); this.onSuccess = this.onSuccess.bind(this); @@ -121,6 +125,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) { part="spinner-button" class="pf-c-button pf-m-progress ${this.buttonClasses}" @click=${this.onClick} + ?disabled=${this.disabled} > ${this.actionTask.render({ pending: () => this.spinner })} diff --git a/web/src/elements/enterprise/EnterpriseStatusBanner.ts b/web/src/elements/enterprise/EnterpriseStatusBanner.ts index 0ac115457..09d376759 100644 --- a/web/src/elements/enterprise/EnterpriseStatusBanner.ts +++ b/web/src/elements/enterprise/EnterpriseStatusBanner.ts @@ -21,10 +21,8 @@ export class EnterpriseStatusBanner extends AKElement { return [PFBanner]; } - firstUpdated(): void { - new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((b) => { - this.summary = b; - }); + async firstUpdated(): Promise { + this.summary = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve(); } renderBanner(): TemplateResult { diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index 0c4590fac..d465a2435 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -31,10 +31,15 @@ export interface KeyUnknown { [key: string]: unknown; } +// Literally the only field `assignValue()` cares about. +type HTMLNamedElement = Pick; + +type AkControlElement = HTMLInputElement & { json: () => string | string[] }; + /** * Recursively assign `value` into `json` while interpreting the dot-path of `element.name` */ -function assignValue(element: HTMLInputElement, value: unknown, json: KeyUnknown): void { +function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown): void { let parent = json; if (!element.name?.includes(".")) { parent[element.name] = value; @@ -60,6 +65,16 @@ export function serializeForm( const json: { [key: string]: unknown } = {}; elements.forEach((element) => { element.requestUpdate(); + if (element.hidden) { + return; + } + + // TODO: Tighten up the typing so that we can handle both. + if ("akControl" in element.dataset) { + assignValue(element, (element as unknown as AkControlElement).json(), json); + return; + } + const inputElement = element.querySelector("[name]"); if (element.hidden || !inputElement) { return; diff --git a/web/src/elements/forms/FormElement.ts b/web/src/elements/forms/FormElement.ts index bb408af88..b28c3bd7d 100644 --- a/web/src/elements/forms/FormElement.ts +++ b/web/src/elements/forms/FormElement.ts @@ -6,19 +6,20 @@ import { customElement, property } from "lit/decorators.js"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { ErrorDetail } from "@goauthentik/api"; /** * This is used in two places outside of Flow, and in both cases is used primarily to - * display content, not take input. It displays the TOPT QR code, and the static + * display content, not take input. It displays the TOTP QR code, and the static * recovery tokens. But it's used a lot in Flow. */ @customElement("ak-form-element") export class FormElement extends AKElement { static get styles(): CSSResult[] { - return [PFForm, PFFormControl]; + return [PFBase, PFForm, PFFormControl]; } @property() @@ -28,7 +29,16 @@ export class FormElement extends AKElement { required = false; @property({ attribute: false }) - errors?: ErrorDetail[]; + set errors(value: ErrorDetail[] | undefined) { + this._errors = value; + const hasError = (value || []).length > 0; + this.querySelectorAll("input").forEach((input) => { + input.setAttribute("aria-invalid", hasError.toString()); + }); + this.requestUpdate(); + } + + _errors?: ErrorDetail[]; updated(): void { this.querySelectorAll("input[autofocus]").forEach((input) => { @@ -45,8 +55,12 @@ export class FormElement extends AKElement { : html``} - ${(this.errors || []).map((error) => { - return html`

${error.string}

`; + ${(this._errors || []).map((error) => { + return html`

+ + ${error.string} +

`; })} `; } diff --git a/web/src/elements/oauth/UserRefreshList.ts b/web/src/elements/oauth/UserRefreshList.ts index 288bf032b..2c5915f74 100644 --- a/web/src/elements/oauth/UserRefreshList.ts +++ b/web/src/elements/oauth/UserRefreshList.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { uiConfig } from "@goauthentik/common/ui/config"; -import { PFColor } from "@goauthentik/elements/Label"; +import "@goauthentik/components/ak-status-label"; import "@goauthentik/elements/forms/DeleteBulkForm"; import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { Table, TableColumn } from "@goauthentik/elements/table/Table"; @@ -85,9 +85,7 @@ export class UserOAuthRefreshList extends Table { row(item: TokenModel): TemplateResult[] { return [ html` ${item.provider?.name} `, - html` - ${item.revoked ? msg("Yes") : msg("No")} - `, + html``, html`${item.expires?.toLocaleString()}`, html`${item.scope.join(", ")}`, ]; diff --git a/web/src/elements/sidebar/SidebarBrand.ts b/web/src/elements/sidebar/SidebarBrand.ts index fa442b36c..b57d336f7 100644 --- a/web/src/elements/sidebar/SidebarBrand.ts +++ b/web/src/elements/sidebar/SidebarBrand.ts @@ -1,6 +1,6 @@ import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; -import { first } from "@goauthentik/common/utils"; -import { AKElement, rootInterface } from "@goauthentik/elements/Base"; +import { AKElement } from "@goauthentik/elements/Base"; +import { WithTenantConfig } from "@goauthentik/elements/Interface/tenantProvider"; import { CSSResult, TemplateResult, css, html } from "lit"; import { customElement } from "lit/decorators.js"; @@ -27,7 +27,7 @@ export const DefaultTenant: CurrentTenant = { }; @customElement("ak-sidebar-brand") -export class SidebarBrand extends AKElement { +export class SidebarBrand extends WithTenantConfig(AKElement) { static get styles(): CSSResult[] { return [ PFBase, @@ -85,10 +85,7 @@ export class SidebarBrand extends AKElement {
authentik Logo diff --git a/web/src/elements/sidebar/SidebarItem.ts b/web/src/elements/sidebar/SidebarItem.ts index 9d5374736..26cdb975e 100644 --- a/web/src/elements/sidebar/SidebarItem.ts +++ b/web/src/elements/sidebar/SidebarItem.ts @@ -144,47 +144,84 @@ export class SidebarItem extends AKElement { return this.renderInner(); } - renderInner(): TemplateResult { - if (this.childItems.length > 0) { - return html`
  • + -
    -
      - -
    -
    -
  • `; + + +
    +
      + +
    +
    + `; + } + + renderWithPathAndChildren() { + return html`
  • + + +
    +
      + +
    +
    +
  • `; + } + + renderWithPath() { + return html` +
    + + + `; + } + + renderWithLabel() { + html` + + + + `; + } + + renderInner() { + if (this.childItems.length > 0) { + return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren(); } + return html`
  • - ${this.path - ? html` - - - - ` - : html` - - - - `} + ${this.path ? this.renderWithPath() : this.renderWithLabel()}
  • `; } } diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 642af4801..82fb9f5ae 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -13,6 +13,7 @@ import "@goauthentik/elements/table/TableSearch"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; import { property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; @@ -26,6 +27,11 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { Pagination, ResponseError } from "@goauthentik/api"; +export interface TableLike { + order?: string; + fetch: () => void; +} + export class TableColumn { title: string; orderBy?: string; @@ -37,19 +43,15 @@ export class TableColumn { this.orderBy = orderBy; } - headerClickHandler(table: Table): void { + headerClickHandler(table: TableLike): void { if (!this.orderBy) { return; } - if (table.order === this.orderBy) { - table.order = `-${this.orderBy}`; - } else { - table.order = this.orderBy; - } + table.order = table.order === this.orderBy ? `-${this.orderBy}` : this.orderBy; table.fetch(); } - private getSortIndicator(table: Table): string { + private getSortIndicator(table: TableLike): string { switch (table.order) { case this.orderBy: return "fa-long-arrow-alt-down"; @@ -60,7 +62,7 @@ export class TableColumn { } } - renderSortable(table: Table): TemplateResult { + renderSortable(table: TableLike): TemplateResult { return html` `; } - render(table: Table): TemplateResult { - return html` + render(table: TableLike): TemplateResult { + const classes = { + "pf-c-table__sort": !!this.orderBy, + "pf-m-selected": table.order === this.orderBy || table.order === `-${this.orderBy}`, + }; + + return html` ${this.orderBy ? this.renderSortable(table) : html`${this.title}`} `; } @@ -96,7 +94,7 @@ export interface PaginatedResponse { results: Array; } -export abstract class Table extends AKElement { +export abstract class Table extends AKElement implements TableLike { abstract apiEndpoint(page: number): Promise>; abstract columns(): TableColumn[]; abstract row(item: T): TemplateResult[]; @@ -130,6 +128,12 @@ export abstract class Table extends AKElement { @property({ type: Boolean }) checkbox = false; + @property({ type: Boolean }) + clickable = false; + + @property({ attribute: false }) + clickHandler: (item: T) => void = () => {}; + @property({ type: Boolean }) radioSelect = false; @@ -194,7 +198,6 @@ export abstract class Table extends AKElement { this.data = await this.apiEndpoint(this.page); this.error = undefined; this.page = this.data.pagination.current; - const newSelected: T[] = []; const newExpanded: T[] = []; this.data.results.forEach((res) => { const jsonRes = JSON.stringify(res); @@ -214,18 +217,12 @@ export abstract class Table extends AKElement { ); }; } - - const selectedIndex = this.selectedElements.findIndex(comp); - if (selectedIndex > -1) { - newSelected.push(res); - } const expandedIndex = this.expandedElements.findIndex(comp); if (expandedIndex > -1) { newExpanded.push(res); } }); this.isLoading = false; - this.selectedElements = newSelected; this.expandedElements = newExpanded; } catch (ex) { this.isLoading = false; @@ -237,7 +234,7 @@ export abstract class Table extends AKElement { return html`
    - +
    `; @@ -248,11 +245,10 @@ export abstract class Table extends AKElement {
    - ${inner - ? inner - : html`
    ${this.renderObjectCreate()}
    -
    `} + ${inner ?? + html`
    ${this.renderObjectCreate()}
    +
    `}
    @@ -264,14 +260,13 @@ export abstract class Table extends AKElement { } renderError(): TemplateResult { - if (!this.error) { - return html``; - } - return html` - ${this.error instanceof ResponseError - ? html`
    ${this.error.message}
    ` - : html`
    ${this.error.detail}
    `} -
    `; + return this.error + ? html` + ${this.error instanceof ResponseError + ? html`
    ${this.error.message}
    ` + : html`
    ${this.error.detail}
    `} +
    ` + : html``; } private renderRows(): TemplateResult[] | undefined { @@ -301,104 +296,93 @@ export abstract class Table extends AKElement { private renderRowGroup(items: T[]): TemplateResult[] { return items.map((item) => { const itemSelectHandler = (ev: InputEvent | PointerEvent) => { - let checked = false; const target = ev.target as HTMLElement; - if (ev.type === "input") { - checked = (target as HTMLInputElement).checked; - } else if (ev instanceof PointerEvent) { - if (target.classList.contains("ignore-click")) { - return; - } - checked = this.selectedElements.indexOf(item) === -1; - } - if (checked) { - // Prevent double-adding the element to selected items - if (this.selectedElements.indexOf(item) !== -1) { - return; - } - // Add item to selected - this.selectedElements.push(item); - } else { - // Get index of item and remove if selected - const index = this.selectedElements.indexOf(item); - if (index <= -1) return; - this.selectedElements.splice(index, 1); - } - this.requestUpdate(); - // Unset select-all if selectedElements is empty - const selectAllCheckbox = - this.shadowRoot?.querySelector("[name=select-all]"); - if (!selectAllCheckbox) { + if (ev instanceof PointerEvent && target.classList.contains("ignore-click")) { return; } - if (this.selectedElements.length < 1) { - selectAllCheckbox.checked = false; - this.requestUpdate(); + + const selected = this.selectedElements.includes(item); + const checked = + ev instanceof PointerEvent ? !selected : (target as HTMLInputElement).checked; + + if ((checked && selected) || !(checked || selected)) { + return; } + + this.selectedElements = this.selectedElements.filter((i) => i !== item); + if (checked) { + this.selectedElements.push(item); + } + + const selectAllCheckbox = + this.shadowRoot?.querySelector("[name=select-all]"); + if (selectAllCheckbox && this.selectedElements.length < 1) { + selectAllCheckbox.checked = false; + } + + this.requestUpdate(); }; - return html` + + const renderCheckbox = () => + html` + + `; + + const handleExpansion = (ev: Event) => { + ev.stopPropagation(); + const expanded = this.expandedElements.includes(item); + this.expandedElements = this.expandedElements.filter((i) => i !== item); + if (!expanded) { + this.expandedElements.push(item); + } + this.requestUpdate(); + }; + + const expandedClass = { + "pf-m-expanded": this.expandedElements.includes(item), + }; + + const renderExpansion = () => { + return html` + + `; + }; + + return html` { + this.clickHandler(item); + } + : itemSelectHandler} > - ${this.checkbox - ? html` - - ` - : html``} - ${this.expandable - ? html` - - ` - : html``} + ${this.checkbox ? renderCheckbox() : html``} + ${this.expandable ? renderExpansion() : html``} ${this.row(item).map((col) => { return html`${col}`; })} - + - ${this.expandedElements.indexOf(item) > -1 ? this.renderExpanded(item) : html``} + ${this.expandedElements.includes(item) ? this.renderExpanded(item) : html``} `; }); @@ -425,28 +409,24 @@ export abstract class Table extends AKElement { } renderSearch(): TemplateResult { - if (!this.searchEnabled()) { - return html``; - } - return html`
    - { - this.search = value; - this.fetch(); - updateURLParams({ - search: value, - }); - }} - > - -
    `; - } + const runSearch = (value: string) => { + this.search = value; + updateURLParams({ + search: value, + }); + this.fetch(); + }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - renderSelectedChip(item: T): TemplateResult { - return html``; + return !this.searchEnabled() + ? html`` + : html`
    + + +
    `; } renderToolbarContainer(): TemplateResult { @@ -456,18 +436,7 @@ export abstract class Table extends AKElement {
    ${this.renderToolbar()}
    ${this.renderToolbarAfter()}
    ${this.renderToolbarSelected()}
    - ${this.paginated - ? html` { - this.page = page; - updateURLParams({ tablePage: page }); - this.fetch(); - }} - > - ` - : html``} + ${this.paginated ? this.renderTablePagination() : html``}
    `; } @@ -476,57 +445,87 @@ export abstract class Table extends AKElement { this.fetch(); } + /* The checkbox on the table header row that allows the user to "activate all on this page," + * "deactivate all on this page" with a single click. + */ + renderAllOnThisPageCheckbox(): TemplateResult { + const checked = + this.selectedElements.length === this.data?.results.length && + this.selectedElements.length > 0; + + const onInput = (ev: InputEvent) => { + this.selectedElements = (ev.target as HTMLInputElement).checked + ? this.data?.results.slice(0) || [] + : []; + }; + + return html` + + `; + } + + /* For very large tables where the user is selecting a limited number of entries, we provide a + * chip-based subtable at the top that shows the list of selected entries. Long text result in + * ellipsized chips, which is sub-optimal. + */ + renderSelectedChip(_item: T): TemplateResult { + // Override this for chip-based displays + return html``; + } + + get needChipGroup() { + return this.checkbox && this.checkboxChip; + } + + renderChipGroup(): TemplateResult { + return html` + ${this.selectedElements.map((el) => { + return html`${this.renderSelectedChip(el)}`; + })} + `; + } + + /* A simple pagination display, shown at both the top and bottom of the page. */ + renderTablePagination(): TemplateResult { + const handler = (page: number) => { + updateURLParams({ tablePage: page }); + this.page = page; + this.fetch(); + }; + + return html` + + + `; + } + renderTable(): TemplateResult { - return html` ${this.checkbox && this.checkboxChip - ? html` - ${this.selectedElements.map((el) => { - return html`${this.renderSelectedChip(el)}`; - })} - ` - : html``} + const renderBottomPagination = () => + html`
    ${this.renderTablePagination()}
    `; + + return html` ${this.needChipGroup ? this.renderChipGroup() : html``} ${this.renderToolbarContainer()} - ${this.checkbox - ? html`` - : html``} + ${this.checkbox ? this.renderAllOnThisPageCheckbox() : html``} ${this.expandable ? html`` : html``} ${this.columns().map((col) => col.render(this))} ${this.renderRows()}
    - 0} - @input=${(ev: InputEvent) => { - if ((ev.target as HTMLInputElement).checked) { - this.selectedElements = - this.data?.results.slice(0) || []; - } else { - this.selectedElements = []; - } - }} - /> -
    - ${this.paginated - ? html`
    - { - this.page = page; - this.fetch(); - }} - > - -
    ` - : html``}`; + ${this.paginated ? renderBottomPagination() : html``}`; } render(): TemplateResult { diff --git a/web/src/elements/table/TableModal.ts b/web/src/elements/table/TableModal.ts index 328f5ffdf..341951fe6 100644 --- a/web/src/elements/table/TableModal.ts +++ b/web/src/elements/table/TableModal.ts @@ -19,7 +19,18 @@ export abstract class TableModal extends Table { size: PFSize = PFSize.Large; @property({ type: Boolean }) - open = false; + set open(value: boolean) { + this._open = value; + if (value) { + this.fetch(); + } + } + + get open(): boolean { + return this._open; + } + + _open = false; static get styles(): CSSResult[] { return super.styles.concat( @@ -43,6 +54,13 @@ export abstract class TableModal extends Table { }); } + public async fetch(): Promise { + if (!this.open) { + return; + } + return super.fetch(); + } + resetForms(): void { this.querySelectorAll("[slot=form]").forEach((form) => { if ("resetForm" in form) { diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts new file mode 100644 index 000000000..4273ab6f9 --- /dev/null +++ b/web/src/elements/types.ts @@ -0,0 +1,3 @@ +export interface AdoptedStyleSheetsElement { + adoptedStyleSheets: readonly CSSStyleSheet[]; +} diff --git a/web/src/elements/utils/ensureCSSStyleSheet.ts b/web/src/elements/utils/ensureCSSStyleSheet.ts new file mode 100644 index 000000000..26f2ff898 --- /dev/null +++ b/web/src/elements/utils/ensureCSSStyleSheet.ts @@ -0,0 +1,4 @@ +import { CSSResult } from "lit"; + +export const ensureCSSStyleSheet = (css: CSSStyleSheet | CSSResult): CSSStyleSheet => + css instanceof CSSResult ? css.styleSheet! : css; diff --git a/web/src/elements/utils/getRootStyle.ts b/web/src/elements/utils/getRootStyle.ts new file mode 100644 index 000000000..f91d63e5f --- /dev/null +++ b/web/src/elements/utils/getRootStyle.ts @@ -0,0 +1,5 @@ +export function getRootStyle(selector: string, element: HTMLElement = document.documentElement) { + return getComputedStyle(element, null).getPropertyValue(selector); +} + +export default getRootStyle; diff --git a/web/src/enterprise/rac/index.ts b/web/src/enterprise/rac/index.ts new file mode 100644 index 000000000..bf09917d4 --- /dev/null +++ b/web/src/enterprise/rac/index.ts @@ -0,0 +1,330 @@ +import { TITLE_DEFAULT } from "@goauthentik/app/common/constants"; +import { Interface } from "@goauthentik/elements/Interface"; +import "@goauthentik/elements/LoadingOverlay"; +import Guacamole from "guacamole-common-js"; + +import { msg, str } from "@lit/localize"; +import { CSSResult, TemplateResult, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; + +import AKGlobal from "@goauthentik/common/styles/authentik.css"; +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFPage from "@patternfly/patternfly/components/Page/page.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +enum GuacClientState { + IDLE = 0, + CONNECTING = 1, + WAITING = 2, + CONNECTED = 3, + DISCONNECTING = 4, + DISCONNECTED = 5, +} + +const AUDIO_INPUT_MIMETYPE = "audio/L16;rate=44100,channels=2"; +const RECONNECT_ATTEMPTS_INITIAL = 5; +const RECONNECT_ATTEMPTS = 5; + +@customElement("ak-rac") +export class RacInterface extends Interface { + static get styles(): CSSResult[] { + return [ + PFBase, + PFPage, + PFContent, + AKGlobal, + css` + :host { + cursor: none; + } + canvas { + z-index: unset !important; + } + .container { + overflow: hidden; + height: 100vh; + background-color: black; + display: flex; + justify-content: center; + align-items: center; + } + ak-loading-overlay { + z-index: 5; + } + `, + ]; + } + + client?: Guacamole.Client; + tunnel?: Guacamole.Tunnel; + + @state() + container?: HTMLElement; + + @state() + clientState?: GuacClientState; + + @state() + reconnectingMessage = ""; + + @property() + token?: string; + + @property() + endpointName?: string; + + @state() + clipboardWatcherTimer = 0; + + _previousClipboardValue: unknown; + + // Set to `true` if we've successfully connected once + hasConnected = false; + // Keep track of current connection attempt + connectionAttempt = 0; + + static domSize(): { width: number; height: number } { + const size = document.body.getBoundingClientRect(); + return { + width: size.width * window.devicePixelRatio, + height: size.height * window.devicePixelRatio, + }; + } + + constructor() { + super(); + this.initKeyboard(); + this.checkClipboard(); + this.clipboardWatcherTimer = setInterval( + this.checkClipboard.bind(this), + 500, + ) as unknown as number; + } + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener( + "focus", + () => { + this.checkClipboard(); + }, + { + capture: false, + }, + ); + window.addEventListener("resize", () => { + this.client?.sendSize( + Math.floor(RacInterface.domSize().width), + Math.floor(RacInterface.domSize().height), + ); + }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + clearInterval(this.clipboardWatcherTimer); + } + + async firstUpdated(): Promise { + this.updateTitle(); + const wsUrl = `${window.location.protocol.replace("http", "ws")}//${ + window.location.host + }/ws/rac/${this.token}/`; + this.tunnel = new Guacamole.WebSocketTunnel(wsUrl); + this.tunnel.receiveTimeout = 10 * 1000; // 10 seconds + this.tunnel.onerror = (status) => { + console.debug("authentik/rac: tunnel error: ", status); + this.reconnect(); + }; + this.client = new Guacamole.Client(this.tunnel); + this.client.onerror = (err) => { + console.debug("authentik/rac: error: ", err); + this.reconnect(); + }; + this.client.onstatechange = (state) => { + this.clientState = state; + if (state === GuacClientState.CONNECTED) { + this.onConnected(); + } + }; + this.client.onclipboard = (stream, mimetype) => { + // If the received data is text, read it as a simple string + if (/^text\//.exec(mimetype)) { + const reader = new Guacamole.StringReader(stream); + let data = ""; + reader.ontext = (text) => { + data += text; + }; + reader.onend = () => { + this._previousClipboardValue = data; + navigator.clipboard.writeText(data); + }; + } else { + const reader = new Guacamole.BlobReader(stream, mimetype); + reader.onend = () => { + const blob = reader.getBlob(); + navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob, + }), + ]); + }; + } + console.debug("authentik/rac: updated clipboard from remote"); + }; + const params = new URLSearchParams(); + params.set("screen_width", Math.floor(RacInterface.domSize().width).toString()); + params.set("screen_height", Math.floor(RacInterface.domSize().height).toString()); + params.set("screen_dpi", (window.devicePixelRatio * 96).toString()); + this.client.connect(params.toString()); + } + + reconnect(): void { + this.clientState = undefined; + this.connectionAttempt += 1; + if (!this.hasConnected) { + // Check connection attempts if we haven't had a successful connection + if (this.connectionAttempt >= RECONNECT_ATTEMPTS_INITIAL) { + this.hasConnected = true; + this.reconnectingMessage = msg( + str`Connection failed after ${this.connectionAttempt} attempts.`, + ); + return; + } + } else { + if (this.connectionAttempt >= RECONNECT_ATTEMPTS) { + this.reconnectingMessage = msg( + str`Connection failed after ${this.connectionAttempt} attempts.`, + ); + return; + } + } + const delay = 500 * this.connectionAttempt; + this.reconnectingMessage = msg( + str`Re-connecting in ${Math.max(1, delay / 1000)} second(s).`, + ); + setTimeout(() => { + this.firstUpdated(); + }, delay); + } + + updateTitle(): void { + let title = this.tenant?.brandingTitle || TITLE_DEFAULT; + if (this.endpointName) { + title = `${this.endpointName} - ${title}`; + } + document.title = `${title}`; + } + + onConnected(): void { + console.debug("authentik/rac: connected"); + if (!this.client) { + return; + } + this.hasConnected = true; + this.container = this.client.getDisplay().getElement(); + this.initMouse(this.container); + this.client?.sendSize( + Math.floor(RacInterface.domSize().width), + Math.floor(RacInterface.domSize().height), + ); + } + + initMouse(container: HTMLElement): void { + const mouse = new Guacamole.Mouse(container); + const handler = (mouseState: Guacamole.Mouse.State, scaleMouse = false) => { + if (!this.client) return; + + if (scaleMouse) { + mouseState.y = mouseState.y / this.client.getDisplay().getScale(); + mouseState.x = mouseState.x / this.client.getDisplay().getScale(); + } + + this.client.sendMouseState(mouseState); + }; + // @ts-ignore + mouse.onEach(["mouseup", "mousedown"], (ev: Guacamole.Mouse.Event) => { + this.container?.focus(); + handler(ev.state); + }); + // @ts-ignore + mouse.on("mousemove", (ev: Guacamole.Mouse.Event) => { + handler(ev.state, true); + }); + } + + initAudioInput(): void { + const stream = this.client?.createAudioStream(AUDIO_INPUT_MIMETYPE); + if (!stream) return; + // Guacamole.AudioPlayer + const recorder = Guacamole.AudioRecorder.getInstance(stream, AUDIO_INPUT_MIMETYPE); + // If creation of the AudioRecorder failed, simply end the stream + if (!recorder) { + stream.sendEnd(); + return; + } + // Otherwise, ensure that another audio stream is created after this + // audio stream is closed + recorder.onclose = this.initAudioInput.bind(this); + } + + initKeyboard(): void { + const keyboard = new Guacamole.Keyboard(document); + keyboard.onkeydown = (keysym) => { + this.client?.sendKeyEvent(1, keysym); + }; + keyboard.onkeyup = (keysym) => { + this.client?.sendKeyEvent(0, keysym); + }; + } + + async checkClipboard(): Promise { + try { + if (!this._previousClipboardValue) { + this._previousClipboardValue = await navigator.clipboard.readText(); + return; + } + const newValue = await navigator.clipboard.readText(); + if (newValue !== this._previousClipboardValue) { + console.debug(`authentik/rac: new clipboard value: ${newValue}`); + this._previousClipboardValue = newValue; + this.writeClipboard(newValue); + } + } catch (ex) { + // The error is most likely caused by the document not being in focus + // in which case we can ignore it and just retry + if (ex instanceof DOMException) { + return; + } + console.warn("authentik/rac: error reading clipboard", ex); + } + } + + private writeClipboard(value: string) { + if (!this.client) { + return; + } + const stream = this.client.createClipboardStream("text/plain"); + const writer = new Guacamole.StringWriter(stream); + writer.sendText(value); + writer.sendEnd(); + console.debug("authentik/rac: Sent clipboard"); + } + + render(): TemplateResult { + return html` + ${this.clientState !== GuacClientState.CONNECTED + ? html` + + + ${this.hasConnected + ? html`${this.reconnectingMessage}` + : html`${msg("Connecting...")}`} + + + ` + : html``} +
    ${this.container}
    + `; + } +} diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 29a90b428..e0d31e421 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -8,7 +8,7 @@ import { globalAK } from "@goauthentik/common/global"; import { configureSentry } from "@goauthentik/common/sentry"; import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/LoadingOverlay"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/flow/sources/apple/AppleLoginInit"; @@ -18,7 +18,7 @@ import "@goauthentik/flow/stages/RedirectStage"; import { StageHost } from "@goauthentik/flow/stages/base"; import { msg } from "@lit/localize"; -import { CSSResult, TemplateResult, css, html, render } from "lit"; +import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { until } from "lit/directives/until.js"; @@ -37,8 +37,8 @@ import { ContextualFlowInfo, FlowChallengeResponseRequest, FlowErrorChallenge, + FlowLayoutEnum, FlowsApi, - LayoutEnum, ResponseError, ShellChallenge, UiThemeEnum, @@ -46,7 +46,8 @@ import { @customElement("ak-flow-executor") export class FlowExecutor extends Interface implements StageHost { - flowSlug?: string; + @property() + flowSlug: string = window.location.pathname.split("/")[3]; private _challenge?: ChallengeTypes; @@ -94,6 +95,9 @@ export class FlowExecutor extends Interface implements StageHost { static get styles(): CSSResult[] { return [PFBase, PFLogin, PFDrawer, PFButton, PFTitle, PFList, PFBackgroundImage].concat(css` + :host { + --pf-c-login__main-body--PaddingBottom: var(--pf-global--spacer--2xl); + } .pf-c-background-image::before { --pf-c-background-image--BackgroundImage: var(--ak-flow-background); --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background); @@ -111,6 +115,11 @@ export class FlowExecutor extends Interface implements StageHost { background-color: transparent; } /* layouts */ + @media (min-height: 60rem) { + .pf-c-login.stacked .pf-c-login__main { + margin-top: 13rem; + } + } .pf-c-login__container.content-right { grid-template-areas: "header main" @@ -146,13 +155,28 @@ export class FlowExecutor extends Interface implements StageHost { :host([theme="dark"]) .pf-c-login.sidebar_right .pf-c-list { color: var(--ak-dark-foreground); } + .pf-c-brand { + padding-top: calc( + var(--pf-c-login__main-footer-links--PaddingTop) + + var(--pf-c-login__main-footer-links--PaddingBottom) + + var(--pf-c-login__main-body--PaddingBottom) + ); + max-height: 9rem; + } + .ak-brand { + display: flex; + justify-content: center; + } + .ak-brand img { + padding: 0 2rem; + max-height: inherit; + } `); } constructor() { super(); this.ws = new WebsocketClient(); - this.flowSlug = window.location.pathname.split("/")[3]; if (window.location.search.includes("inspector")) { this.inspectorOpen = !this.inspectorOpen; } @@ -165,75 +189,68 @@ export class FlowExecutor extends Interface implements StageHost { return globalAK()?.tenant.uiTheme || UiThemeEnum.Automatic; } - submit(payload?: FlowChallengeResponseRequest): Promise { + async submit(payload?: FlowChallengeResponseRequest): Promise { if (!payload) return Promise.reject(); if (!this.challenge) return Promise.reject(); // @ts-ignore payload.component = this.challenge.component; this.loading = true; - return new FlowsApi(DEFAULT_CONFIG) - .flowsExecutorSolve({ - flowSlug: this.flowSlug || "", + try { + const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({ + flowSlug: this.flowSlug, query: window.location.search.substring(1), flowChallengeResponseRequest: payload, - }) - .then((data) => { - if (this.inspectorOpen) { - window.dispatchEvent( - new CustomEvent(EVENT_FLOW_ADVANCE, { - bubbles: true, - composed: true, - }), - ); - } - this.challenge = data; - if (this.challenge.flowInfo) { - this.flowInfo = this.challenge.flowInfo; - } - if (this.challenge.responseErrors) { - return false; - } - return true; - }) - .catch((e: Error | ResponseError) => { - this.errorMessage(e); - return false; - }) - .finally(() => { - this.loading = false; - return false; }); + if (this.inspectorOpen) { + window.dispatchEvent( + new CustomEvent(EVENT_FLOW_ADVANCE, { + bubbles: true, + composed: true, + }), + ); + } + this.challenge = challenge; + if (this.challenge.flowInfo) { + this.flowInfo = this.challenge.flowInfo; + } + if (this.challenge.responseErrors) { + return false; + } + return true; + } catch (exc: unknown) { + this.errorMessage(exc as Error | ResponseError); + return false; + } finally { + this.loading = false; + } } - firstUpdated(): void { + async firstUpdated(): Promise { configureSentry(); this.loading = true; - new FlowsApi(DEFAULT_CONFIG) - .flowsExecutorGet({ - flowSlug: this.flowSlug || "", + try { + const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorGet({ + flowSlug: this.flowSlug, query: window.location.search.substring(1), - }) - .then((challenge) => { - if (this.inspectorOpen) { - window.dispatchEvent( - new CustomEvent(EVENT_FLOW_ADVANCE, { - bubbles: true, - composed: true, - }), - ); - } - this.challenge = challenge; - if (this.challenge.flowInfo) { - this.flowInfo = this.challenge.flowInfo; - } - }) - .catch((e: Error | ResponseError) => { - // Catch JSON or Update errors - this.errorMessage(e); - }) - .finally(() => { - this.loading = false; }); + if (this.inspectorOpen) { + window.dispatchEvent( + new CustomEvent(EVENT_FLOW_ADVANCE, { + bubbles: true, + composed: true, + }), + ); + } + this.challenge = challenge; + if (this.challenge.flowInfo) { + this.flowInfo = this.challenge.flowInfo; + } + } catch (exc: unknown) { + // Catch JSON or Update errors + this.errorMessage(exc as Error | ResponseError); + } finally { + this.loading = false; + } } async errorMessage(error: Error | ResponseError): Promise { @@ -412,12 +429,15 @@ export class FlowExecutor extends Interface implements StageHost { } renderChallengeWrapper(): TemplateResult { + const logo = html``; if (!this.challenge) { - return html` - `; + return html`${logo} + `; } return html` - ${this.loading ? html`` : html``} + ${this.loading ? html`` : nothing} ${logo} ${until(this.renderChallenge())} `; } @@ -433,7 +453,7 @@ export class FlowExecutor extends Interface implements StageHost { } getLayout(): string { - const prefilledFlow = globalAK()?.flow?.layout || LayoutEnum.Stacked; + const prefilledFlow = globalAK()?.flow?.layout || FlowLayoutEnum.Stacked; if (this.challenge) { return this.challenge?.flowInfo?.layout || prefilledFlow; } @@ -443,53 +463,19 @@ export class FlowExecutor extends Interface implements StageHost { getLayoutClass(): string { const layout = this.getLayout(); switch (layout) { - case LayoutEnum.ContentLeft: + case FlowLayoutEnum.ContentLeft: return "pf-c-login__container"; - case LayoutEnum.ContentRight: + case FlowLayoutEnum.ContentRight: return "pf-c-login__container content-right"; - case LayoutEnum.Stacked: + case FlowLayoutEnum.Stacked: default: return "ak-login-container"; } } - renderBackgroundOverlay(): TemplateResult { - const overlaySVG = html` - - - - - - - - - - `; - render(overlaySVG, document.body); - return overlaySVG; - } - render(): TemplateResult { return html` -
    ${this.renderBackgroundOverlay()}
    +
    @@ -497,14 +483,6 @@ export class FlowExecutor extends Interface implements StageHost {