mirror of
https://github.com/goauthentik/authentik
synced 2026-05-14 10:56:52 +02:00
Compare commits
30 Commits
web/main/d
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7e25fff1a | ||
|
|
688b3a9f8b | ||
|
|
8f48e18854 | ||
|
|
306f75be59 | ||
|
|
982c3cf4dc | ||
|
|
156cda6cb6 | ||
|
|
9f5125cf6b | ||
|
|
a84411363e | ||
|
|
9f3bb0210b | ||
|
|
d1271502ef | ||
|
|
d1065b2d49 | ||
|
|
aa19227e30 | ||
|
|
d7a2861bbe | ||
|
|
5aae9c9afa | ||
|
|
93cb48c928 | ||
|
|
55f7f93a24 | ||
|
|
5a608a4235 | ||
|
|
77d023758f | ||
|
|
f9edafd374 | ||
|
|
d94219eb0e | ||
|
|
0871aa2cf3 | ||
|
|
e0fe99d0b8 | ||
|
|
c1acf53585 | ||
|
|
baf4eed0d9 | ||
|
|
60e1192a7a | ||
|
|
2608e02d6e | ||
|
|
0a5928fbcb | ||
|
|
7b99b02b4a | ||
|
|
dfeadc9ebe | ||
|
|
7ff64fbd09 |
@@ -1,10 +1,11 @@
|
||||
"""Helper script to get the actual branch name, docker safe"""
|
||||
|
||||
import os
|
||||
from importlib.metadata import version as package_version
|
||||
from json import dumps
|
||||
from time import time
|
||||
|
||||
from authentik import authentik_version
|
||||
|
||||
# Decide if we should push the image or not
|
||||
should_push = True
|
||||
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
|
||||
@@ -28,7 +29,7 @@ is_release = "dev" not in image_names[0]
|
||||
sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA")
|
||||
|
||||
# 2042.1.0 or 2042.1.0-rc1
|
||||
version = package_version("authentik")
|
||||
version = authentik_version()
|
||||
# 2042.1
|
||||
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
|
||||
prerelease = "-" in version
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
image-arch: ${{ inputs.image_arch }}
|
||||
@@ -58,8 +58,8 @@ jobs:
|
||||
if: ${{ inputs.registry_dockerhub }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.registry_ghcr }}
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
merge-server:
|
||||
@@ -74,15 +74,15 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.registry_dockerhub }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.registry_ghcr }}
|
||||
uses: docker/login-action@v3
|
||||
|
||||
2
.github/workflows/ci-docs.yml
vendored
2
.github/workflows/ci-docs.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-docs
|
||||
- name: Login to Container Registry
|
||||
|
||||
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@@ -278,7 +278,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-server
|
||||
- name: Comment on PR
|
||||
|
||||
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
||||
- name: Login to Container Registry
|
||||
|
||||
168
.github/workflows/release-bump-version.yml
vendored
168
.github/workflows/release-bump-version.yml
vendored
@@ -1,168 +0,0 @@
|
||||
---
|
||||
name: Release - Bump version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version
|
||||
required: true
|
||||
type: string
|
||||
release_reason:
|
||||
description: Release reason
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- bugfix
|
||||
- feature
|
||||
- security
|
||||
- other
|
||||
- prerelease
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
|
||||
jobs:
|
||||
check-inputs:
|
||||
name: Check inputs validity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
run: |
|
||||
echo "${{ inputs.version }}" | grep -E "^[0-9]{4}\.[0-9]{1,2}\.[0-9]+(-rc[0-9]+)?$"
|
||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
major_version: "${{ steps.check.outputs.major_version }}"
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
needs:
|
||||
- check-inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.version }}"
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
bump-helm:
|
||||
name: Bump Helm version
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
title: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
body: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] ${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
|
||||
bump-version:
|
||||
name: Bump version repository
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump feature version
|
||||
if: "${{ inputs.release_reason == 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Bump feature version
|
||||
if: "${{ inputs.release_reason != 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "version: bump to ${{ inputs.version }}"
|
||||
title: "version: bump to ${{ inputs.version }}"
|
||||
body: "version: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
3
.github/workflows/release-publish.yml
vendored
3
.github/workflows/release-publish.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
||||
uses: ./.github/workflows/_reusable-docker-build.yaml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
@@ -23,6 +24,7 @@ jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
@@ -66,6 +68,7 @@ jobs:
|
||||
build-outpost:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
|
||||
210
.github/workflows/release-tag.yml
vendored
210
.github/workflows/release-tag.yml
vendored
@@ -1,39 +1,195 @@
|
||||
---
|
||||
name: Release - On tag
|
||||
name: Release - Tag new version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "version/*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version
|
||||
required: true
|
||||
type: string
|
||||
release_reason:
|
||||
description: Release reason
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- bugfix
|
||||
- feature
|
||||
- security
|
||||
- other
|
||||
- prerelease
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Create Release from Tag
|
||||
check-inputs:
|
||||
name: Check inputs validity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
run: |
|
||||
echo "${{ inputs.version }}" | grep -E '^[0-9]{4}\.(0?[1-9]|1[0-2])\.[0-9]+(-rc[0-9]+)?$'
|
||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
- id: changelog-url
|
||||
run: |
|
||||
if [ "${{ inputs.release_reason }}" = "feature" ] || [ "${{ inputs.release_reason }}" = "prerelease" ]; then
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||
else
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version }} | sed 's/\.//g')"
|
||||
fi
|
||||
echo "changelog_url=${changelog_url}" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
major_version: "${{ steps.check.outputs.major_version }}"
|
||||
changelog_url: "${{ steps.changelog-url.outputs.changelog_url }}"
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Pre-release test
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
needs:
|
||||
- check-inputs
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.version }}"
|
||||
- name: Commit and push
|
||||
run: |
|
||||
make test-docker
|
||||
- id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.1.4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ steps.ev.outputs.version }}
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
name: Release ${{ inputs.version }}
|
||||
draft: true
|
||||
prerelease: ${{ steps.ev.outputs.prerelease == 'true' }}
|
||||
prerelease: ${{ inputs.release_reason == 'prerelease' }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
See ${{ needs.check-inputs.outputs.changelog_url }}
|
||||
bump-helm:
|
||||
name: Bump Helm version
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
title: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
body: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
bump-version:
|
||||
name: Bump version repository
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
if: "${{ inputs.release_reason == 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Bump version
|
||||
if: "${{ inputs.release_reason != 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "version: bump to ${{ inputs.version }}"
|
||||
title: "version: bump to ${{ inputs.version }}"
|
||||
body: "version: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
|
||||
@@ -175,6 +175,7 @@ COPY ./lifecycle/ /lifecycle
|
||||
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
||||
COPY --from=go-builder /go/authentik /bin/authentik
|
||||
COPY ./packages/ /ak-root/packages
|
||||
RUN ln -s /ak-root/packages /packages
|
||||
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
||||
COPY --from=node-builder /work/web/dist/ /web/dist/
|
||||
COPY --from=node-builder /work/web/authentik/ /web/authentik/
|
||||
|
||||
@@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2025.4.x | ✅ |
|
||||
| 2025.6.x | ✅ |
|
||||
| 2025.8.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.8.0-rc1"
|
||||
VERSION = "2025.8.0"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -63,28 +63,6 @@ class TestBrands(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_brand_subdomain_same_suffix(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.all().delete()
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "foo.bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_fallback(self):
|
||||
"""Test fallback brand"""
|
||||
Brand.objects.all().delete()
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.db.models.functions import Length
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.html import _json_script_escapes
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -21,9 +20,9 @@ DEFAULT_BRAND = Brand(domain="fallback")
|
||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||
"""Get brand object for current request"""
|
||||
db_brands = (
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()), match_length=Length("domain"))
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()))
|
||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
||||
.order_by("-match_length", "default")
|
||||
.order_by("default")
|
||||
)
|
||||
brands = list(db_brands.all())
|
||||
if len(brands) < 1:
|
||||
|
||||
@@ -23,6 +23,7 @@ from authentik.events.models import (
|
||||
)
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.stages.email.models import get_template_choices
|
||||
|
||||
|
||||
class NotificationTransportSerializer(ModelSerializer):
|
||||
@@ -30,6 +31,18 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
|
||||
mode_verbose = SerializerMethodField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["email_template"].choices = get_template_choices()
|
||||
|
||||
def validate_email_template(self, value: str) -> str:
|
||||
"""Check validity of email template"""
|
||||
choices = get_template_choices()
|
||||
for path, _ in choices:
|
||||
if path == value:
|
||||
return value
|
||||
raise ValidationError(f"Invalid template '{value}' specified.")
|
||||
|
||||
def get_mode_verbose(self, instance: NotificationTransport) -> str:
|
||||
"""Return selected mode with a UI Label"""
|
||||
return TransportMode(instance.mode).label
|
||||
@@ -52,6 +65,8 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
"webhook_url",
|
||||
"webhook_mapping_body",
|
||||
"webhook_mapping_headers",
|
||||
"email_subject_prefix",
|
||||
"email_template",
|
||||
"send_once",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.11 on 2025-08-14 13:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0011_alter_systemtask_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="email_subject_prefix",
|
||||
field=models.TextField(blank=True, default="authentik Notification: "),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="email_template",
|
||||
field=models.TextField(default="email/event_notification.html"),
|
||||
),
|
||||
]
|
||||
@@ -41,6 +41,7 @@ from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tasks.models import TasksModel
|
||||
from authentik.tenants.models import Tenant
|
||||
@@ -295,6 +296,15 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
|
||||
send_once = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel."
|
||||
),
|
||||
)
|
||||
|
||||
email_subject_prefix = models.TextField(default="authentik Notification: ", blank=True)
|
||||
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
|
||||
|
||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||
webhook_mapping_body = models.ForeignKey(
|
||||
@@ -319,12 +329,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
"Mapping should return a dictionary of key-value pairs"
|
||||
),
|
||||
)
|
||||
send_once = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel."
|
||||
),
|
||||
)
|
||||
|
||||
def send(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to user, called from async task"""
|
||||
@@ -462,7 +466,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
notification=notification,
|
||||
)
|
||||
return None
|
||||
subject_prefix = "authentik Notification: "
|
||||
context = {
|
||||
"key_value": {
|
||||
"user_email": notification.user.email,
|
||||
@@ -490,10 +493,10 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
"from": self.name,
|
||||
}
|
||||
mail = TemplateEmailMessage(
|
||||
subject=subject_prefix + context["title"],
|
||||
subject=self.email_subject_prefix + context["title"],
|
||||
to=[(notification.user.name, notification.user.email)],
|
||||
language=notification.user.locale(),
|
||||
template_name="email/event_notification.html",
|
||||
template_name=self.email_template,
|
||||
template_context=context,
|
||||
)
|
||||
send_mail.send_with_options(args=(mail.__dict__,), rel_obj=self)
|
||||
|
||||
@@ -5,10 +5,12 @@ from unittest.mock import PropertyMock, patch
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik import authentik_full_version
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.api.notification_transports import NotificationTransportSerializer
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
Notification,
|
||||
@@ -18,6 +20,7 @@ from authentik.events.models import (
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.email.models import get_template_choices
|
||||
|
||||
|
||||
class TestEventTransports(TestCase):
|
||||
@@ -138,3 +141,76 @@ class TestEventTransports(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik Notification: custom_foo")
|
||||
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
||||
|
||||
def test_transport_email_custom_template(self):
|
||||
"""Test email transport with custom template"""
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.EMAIL,
|
||||
email_template="email/event_notification.html",
|
||||
)
|
||||
with patch(
|
||||
"authentik.stages.email.models.EmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
transport.send(self.notification)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
||||
|
||||
def test_transport_email_custom_subject_prefix(self):
|
||||
"""Test email transport with custom subject prefix"""
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.EMAIL,
|
||||
email_subject_prefix="[CUSTOM] ",
|
||||
)
|
||||
with patch(
|
||||
"authentik.stages.email.models.EmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
transport.send(self.notification)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "[CUSTOM] custom_foo")
|
||||
|
||||
def test_transport_email_validation(self):
|
||||
"""Test email transport template validation"""
|
||||
|
||||
# Test valid template
|
||||
serializer = NotificationTransportSerializer(
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"mode": TransportMode.EMAIL,
|
||||
"email_template": "email/event_notification.html",
|
||||
}
|
||||
)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
|
||||
# Test invalid template - should fail due to choices validation
|
||||
serializer = NotificationTransportSerializer(
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"mode": TransportMode.EMAIL,
|
||||
"email_template": "invalid/template.html",
|
||||
}
|
||||
)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("email_template", serializer.errors)
|
||||
|
||||
def test_templates_api_endpoint(self):
|
||||
"""Test templates API endpoint returns valid templates"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("authentik_api:emailstage-templates"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIsInstance(data, list)
|
||||
|
||||
# Check that we have at least the default templates
|
||||
template_names = [item["name"] for item in data]
|
||||
self.assertIn("email/event_notification.html", template_names)
|
||||
|
||||
# Verify all templates are valid choices
|
||||
valid_choices = dict(get_template_choices())
|
||||
for template in data:
|
||||
self.assertIn(template["name"], valid_choices)
|
||||
self.assertEqual(template["description"], valid_choices[template["name"]])
|
||||
|
||||
@@ -22,6 +22,7 @@ from sentry_sdk import init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.argv import ArgvIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.dramatiq import DramatiqIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.socket import SocketIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
@@ -109,11 +110,12 @@ def sentry_init(**sentry_init_kwargs):
|
||||
dsn=CONFIG.get("error_reporting.sentry_dsn"),
|
||||
integrations=[
|
||||
ArgvIntegration(),
|
||||
StdlibIntegration(),
|
||||
DjangoIntegration(transaction_style="function_name", cache_spans=True),
|
||||
DramatiqIntegration(),
|
||||
RedisIntegration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
SocketIntegration(),
|
||||
StdlibIntegration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
],
|
||||
before_send=before_send,
|
||||
traces_sampler=traces_sampler,
|
||||
|
||||
@@ -103,7 +103,7 @@ class PasswordPolicy(Policy):
|
||||
if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase:
|
||||
LOGGER.debug("password failed", check="static", reason="amount_lowercase")
|
||||
return PolicyResult(False, self.error_message)
|
||||
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase:
|
||||
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_uppercase:
|
||||
LOGGER.debug("password failed", check="static", reason="amount_uppercase")
|
||||
return PolicyResult(False, self.error_message)
|
||||
if self.amount_symbols > 0:
|
||||
|
||||
@@ -15,18 +15,10 @@ from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
class EmailTemplates(models.TextChoices):
|
||||
"""Templates used for rendering the Email"""
|
||||
|
||||
EMAIL_OTP = (
|
||||
"email/email_otp.html",
|
||||
_("Email OTP"),
|
||||
) # nosec
|
||||
|
||||
|
||||
class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
"""Use Email-based authentication instead of authenticator-based."""
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,6 +32,14 @@ class EmailTemplates(models.TextChoices):
|
||||
"email/account_confirmation.html",
|
||||
_("Account Confirmation"),
|
||||
)
|
||||
EMAIL_OTP = (
|
||||
"email/email_otp.html",
|
||||
_("Email OTP"),
|
||||
) # nosec
|
||||
EVENT_NOTIFICATION = (
|
||||
"email/event_notification.html",
|
||||
_("Event Notification"),
|
||||
)
|
||||
|
||||
|
||||
def get_template_choices():
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestEmailStageTemplates(FlowTestCase):
|
||||
chmod(file2, 0o000) # Remove all permissions so we can't read the file
|
||||
choices = get_template_choices()
|
||||
self.assertEqual(choices[-1][0], Path(file).name)
|
||||
self.assertEqual(len(choices), 3)
|
||||
self.assertEqual(len(choices), 5)
|
||||
unlink(file)
|
||||
unlink(file2)
|
||||
|
||||
|
||||
@@ -100,10 +100,15 @@ class MessagesMiddleware(Middleware):
|
||||
TaskStatus.ERROR,
|
||||
exception,
|
||||
)
|
||||
event_kwargs = {
|
||||
"actor": task.actor_name,
|
||||
}
|
||||
if task.rel_obj:
|
||||
event_kwargs["rel_obj"] = task.rel_obj
|
||||
Event.new(
|
||||
EventAction.SYSTEM_TASK_EXCEPTION,
|
||||
message=f"Task {task.actor_name} encountered an error",
|
||||
actor=task.actor_name,
|
||||
**event_kwargs,
|
||||
).with_exception(exception).save()
|
||||
|
||||
def after_skip_message(self, broker: Broker, message: Message):
|
||||
@@ -151,7 +156,6 @@ class DescriptionMiddleware(Middleware):
|
||||
|
||||
|
||||
class _healthcheck_handler(BaseHTTPRequestHandler):
|
||||
|
||||
def log_request(self, code="-", size="-"):
|
||||
HEALTHCHECK_LOGGER.info(
|
||||
self.path,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.8.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.8.0 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@@ -6753,6 +6753,15 @@
|
||||
"title": "Webhook mapping headers",
|
||||
"description": "Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs"
|
||||
},
|
||||
"email_subject_prefix": {
|
||||
"type": "string",
|
||||
"title": "Email subject prefix"
|
||||
},
|
||||
"email_template": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Email template"
|
||||
},
|
||||
"send_once": {
|
||||
"type": "boolean",
|
||||
"title": "Send once",
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_REDIS__HOST: redis
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.0}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_REDIS__HOST: redis
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.0}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.8.0-rc1
|
||||
2025.8.0
|
||||
@@ -26,7 +26,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.8.0-rc1
|
||||
Default: 2025.8.0
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.0",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.8.0-rc1"
|
||||
version = "2025.8.0"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
19
schema.yml
19
schema.yml
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.8.0-rc1
|
||||
version: 2025.8.0
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@@ -6057,7 +6057,8 @@ paths:
|
||||
/core/users/{id}/recovery_email/:
|
||||
post:
|
||||
operationId: core_users_recovery_email_create
|
||||
description: Send an email with a temporary link that a user can use to recover their account
|
||||
description: Send an email with a temporary link that a user can use to recover
|
||||
their account
|
||||
parameters:
|
||||
- in: query
|
||||
name: email_stage
|
||||
@@ -49344,6 +49345,10 @@ components:
|
||||
nullable: true
|
||||
description: Configure additional headers to be sent. Mapping should return
|
||||
a dictionary of key-value pairs
|
||||
email_subject_prefix:
|
||||
type: string
|
||||
email_template:
|
||||
type: string
|
||||
send_once:
|
||||
type: boolean
|
||||
description: Only send notification once, for example when sending a webhook
|
||||
@@ -49383,6 +49388,11 @@ components:
|
||||
nullable: true
|
||||
description: Configure additional headers to be sent. Mapping should return
|
||||
a dictionary of key-value pairs
|
||||
email_subject_prefix:
|
||||
type: string
|
||||
email_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
send_once:
|
||||
type: boolean
|
||||
description: Only send notification once, for example when sending a webhook
|
||||
@@ -54480,6 +54490,11 @@ components:
|
||||
nullable: true
|
||||
description: Configure additional headers to be sent. Mapping should return
|
||||
a dictionary of key-value pairs
|
||||
email_subject_prefix:
|
||||
type: string
|
||||
email_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
send_once:
|
||||
type: boolean
|
||||
description: Only send notification once, for example when sending a webhook
|
||||
|
||||
4
uv.lock
generated
4
uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
[manifest]
|
||||
@@ -159,7 +159,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.8.0rc1"
|
||||
version = "2025.8.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
|
||||
12
web/package-lock.json
generated
12
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
@@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@goauthentik/api": "^2025.8.0-rc1-1755026430",
|
||||
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
|
||||
"@goauthentik/core": "^1.0.0",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
|
||||
"@goauthentik/eslint-config": "^1.0.5",
|
||||
@@ -1509,9 +1509,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2025.8.0-rc1-1755026430",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.8.0-rc1-1755026430.tgz",
|
||||
"integrity": "sha512-nkXlhnI8ILnpnSqseklRHRWsq9kBCT0CPU/7MotO3kQwl54Ze4j8HAMGNJNLr1VjuKuG5m/qt6SQseXLnazb+w=="
|
||||
"version": "2025.10.0-rc1-1755254677",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.10.0-rc1-1755254677.tgz",
|
||||
"integrity": "sha512-hq+xGPtwaeptEDn92Y40Yb4e7yL2KVvuqy2kWAZLPtr/r9ML82vzNYCfW6bFNPnopDRizjOBIzlD3gNP/2rs8Q=="
|
||||
},
|
||||
"node_modules/@goauthentik/core": {
|
||||
"resolved": "packages/core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -94,7 +94,7 @@
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@goauthentik/api": "^2025.8.0-rc1-1755026430",
|
||||
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
|
||||
"@goauthentik/core": "^1.0.0",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
|
||||
"@goauthentik/eslint-config": "^1.0.5",
|
||||
|
||||
@@ -8,15 +8,19 @@ import "#elements/forms/Radio";
|
||||
import "#elements/forms/SearchSelect/index";
|
||||
import "#elements/utils/TimeDeltaHelp";
|
||||
import "./AdminSettingsFooterLinks.js";
|
||||
import "#elements/CodeMirror";
|
||||
|
||||
import { akFooterLinkInput, IFooterLinkInput } from "./AdminSettingsFooterLinks.js";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { CodeMirrorMode } from "#elements/CodeMirror";
|
||||
import { Form } from "#elements/forms/Form";
|
||||
|
||||
import { AdminApi, FooterLink, Settings, SettingsRequest } from "@goauthentik/api";
|
||||
|
||||
import YAML from "yaml";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@@ -254,6 +258,16 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
value="${this._settings?.defaultTokenLength ?? 60}"
|
||||
help=${msg("Default length of generated tokens")}
|
||||
></ak-number-input>
|
||||
<ak-form-element-horizontal label=${msg("Flags")} name="flags" required>
|
||||
<ak-codemirror
|
||||
mode=${CodeMirrorMode.YAML}
|
||||
value="${YAML.stringify(this._settings?.flags ?? {})}"
|
||||
>
|
||||
</ak-codemirror>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Modify flags to opt into new authentik behaviours early.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
NotificationWebhookMapping,
|
||||
PropertymappingsApi,
|
||||
PropertymappingsNotificationListRequest,
|
||||
StagesApi,
|
||||
TypeCreate,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@@ -33,10 +35,18 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
return transport;
|
||||
});
|
||||
}
|
||||
async load(): Promise<void> {
|
||||
this.templates = await new StagesApi(DEFAULT_CONFIG).stagesEmailTemplatesList();
|
||||
}
|
||||
|
||||
templates?: TypeCreate[];
|
||||
|
||||
@property({ type: Boolean })
|
||||
showWebhook = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
showEmail = false;
|
||||
|
||||
getSuccessMessage(): string {
|
||||
return this.instance
|
||||
? msg("Successfully updated transport.")
|
||||
@@ -56,18 +66,28 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
}
|
||||
|
||||
onModeChange(mode: string | undefined): void {
|
||||
if (
|
||||
mode === NotificationTransportModeEnum.Webhook ||
|
||||
mode === NotificationTransportModeEnum.WebhookSlack
|
||||
) {
|
||||
this.showWebhook = true;
|
||||
} else {
|
||||
this.showWebhook = false;
|
||||
// Reset all flags
|
||||
this.showWebhook = false;
|
||||
this.showEmail = false;
|
||||
|
||||
switch (mode) {
|
||||
case NotificationTransportModeEnum.Webhook:
|
||||
case NotificationTransportModeEnum.WebhookSlack:
|
||||
this.showWebhook = true;
|
||||
break;
|
||||
case NotificationTransportModeEnum.Email:
|
||||
this.showEmail = true;
|
||||
break;
|
||||
case NotificationTransportModeEnum.Local:
|
||||
default:
|
||||
// Both flags remain false
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html` <ak-form-element-horizontal label=${msg("Name")} required name="name">
|
||||
return html`
|
||||
<ak-form-element-horizontal label=${msg("Name")} required name="name">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
@@ -75,6 +95,26 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="sendOnce">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.instance?.sendOnce ?? false}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Send once")}</span>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Mode")} required name="mode">
|
||||
<ak-radio
|
||||
@change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => {
|
||||
@@ -109,7 +149,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
value="${this.instance?.webhookUrl || ""}"
|
||||
input-hint="code"
|
||||
?hidden=${!this.showWebhook}
|
||||
required
|
||||
?required=${this.showWebhook}
|
||||
>
|
||||
</ak-hidden-text-input>
|
||||
<ak-form-element-horizontal
|
||||
@@ -178,26 +218,39 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
>
|
||||
</ak-search-select>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="sendOnce">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.instance?.sendOnce ?? false}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Send once")}</span>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>`;
|
||||
<ak-form-element-horizontal
|
||||
?hidden=${!this.showEmail}
|
||||
?required=${this.showEmail}
|
||||
label=${msg("Email Subject Prefix")}
|
||||
name="emailSubjectPrefix"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value="${this.instance?.emailSubjectPrefix || "authentik Notification: "}"
|
||||
class="pf-c-form-control"
|
||||
?hidden=${!this.showEmail}
|
||||
?required=${this.showEmail}
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
?hidden=${!this.showEmail}
|
||||
?required=${this.showEmail}
|
||||
label=${msg("Email Template")}
|
||||
name="emailTemplate"
|
||||
>
|
||||
<select name="users" class="pf-c-form-control">
|
||||
${this.templates?.map((template) => {
|
||||
const selected =
|
||||
this.instance?.emailTemplate === template.name ||
|
||||
(!this.instance?.emailTemplate &&
|
||||
template.name === "email/event_notification.html");
|
||||
return html`<option value=${ifDefined(template.name)} ?selected=${selected}>
|
||||
${template.description}
|
||||
</option>`;
|
||||
})}
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ export class PropertyMappingProviderRACForm extends BasePropertyMappingForm<RACP
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.staticSettings.username)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
@@ -86,7 +85,6 @@ export class PropertyMappingProviderRACForm extends BasePropertyMappingForm<RACP
|
||||
type="password"
|
||||
value="${ifDefined(this.instance?.staticSettings.password)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
@@ -137,11 +135,7 @@ export class PropertyMappingProviderRACForm extends BasePropertyMappingForm<RACP
|
||||
</ak-form-group>
|
||||
<ak-form-group label="${msg("Advanced settings")}">
|
||||
<div class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Expression")}
|
||||
required
|
||||
name="expression"
|
||||
>
|
||||
<ak-form-element-horizontal label=${msg("Expression")} name="expression">
|
||||
<ak-codemirror
|
||||
mode=${CodeMirrorMode.Python}
|
||||
value="${ifDefined(this.instance?.expression)}"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FlowsInstancesListDesignationEnum,
|
||||
FlowsInstancesListRequest,
|
||||
StagesApi,
|
||||
TypeCreate,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@@ -33,6 +34,12 @@ export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmai
|
||||
return stage;
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.templates = await new StagesApi(DEFAULT_CONFIG).stagesEmailTemplatesList();
|
||||
}
|
||||
|
||||
templates?: TypeCreate[];
|
||||
|
||||
@property({ type: Boolean })
|
||||
showConnectionSettings = false;
|
||||
|
||||
@@ -262,6 +269,28 @@ export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmai
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Template")} name="template">
|
||||
<select
|
||||
class="pf-c-form-control"
|
||||
?disabled=${!this.templates || this.templates.length === 0}
|
||||
>
|
||||
${this.templates && this.templates.length > 0
|
||||
? this.templates.map((template: TypeCreate) => {
|
||||
return html`<option
|
||||
value="${template.name}"
|
||||
?selected=${this.instance?.template === template.name ||
|
||||
(!this.instance?.template &&
|
||||
template.name === "email/email_otp.html")}
|
||||
>
|
||||
${template.description}
|
||||
</option>`;
|
||||
})
|
||||
: html`<option value="">${msg("Loading templates...")}</option>`}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Template used for the verification email.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>`;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,22 @@ export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Trusted types policy that removes all HTML content.
|
||||
*
|
||||
*
|
||||
* @returns {TrustedHTML} All remaining text content.
|
||||
*/
|
||||
export const StripHTMLTrustPolicy = trustedTypes.createPolicy("authentik-strip-html", {
|
||||
createHTML: (untrustedHTML: string) => {
|
||||
return DOMPurify.sanitize(untrustedHTML, {
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Trusted types policy, stripping all HTML content.
|
||||
*
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import "#elements/buttons/Dropdown";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { StripHTMLTrustPolicy } from "#common/purify";
|
||||
import { rootInterface } from "#common/theme";
|
||||
|
||||
import { FormAssociated, FormAssociatedElement } from "#elements/forms/form-associated-element";
|
||||
import { PaginatedResponse } from "#elements/table/Table";
|
||||
|
||||
import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { css, CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFSearchInput from "@patternfly/patternfly/components/SearchInput/search-input.css";
|
||||
@@ -25,40 +29,26 @@ export class QL extends DjangoQL {
|
||||
textareaResize() {}
|
||||
}
|
||||
|
||||
@customElement("ak-search-ql")
|
||||
export class QLSearch extends AKElement {
|
||||
@property()
|
||||
value?: string;
|
||||
/**
|
||||
* Given an array or length, return logical index of the element at the given delta.
|
||||
* This is effectively a modulo loop, allowing for positive and negative deltas.
|
||||
*/
|
||||
function torusIndex(lengthLike: number | ArrayLike<number>, delta: number): number {
|
||||
const length = typeof lengthLike === "number" ? lengthLike : lengthLike.length;
|
||||
|
||||
@query("[name=search]")
|
||||
searchElement?: HTMLTextAreaElement;
|
||||
|
||||
@state()
|
||||
menuOpen = false;
|
||||
|
||||
@property()
|
||||
onSearch?: (value: string) => void;
|
||||
|
||||
@state()
|
||||
selected?: number;
|
||||
|
||||
@state()
|
||||
cursorX: number = 0;
|
||||
|
||||
@state()
|
||||
cursorY: number = 0;
|
||||
|
||||
ql?: QL;
|
||||
canvas?: CanvasRenderingContext2D;
|
||||
|
||||
set apiResponse(value: PaginatedResponse<unknown> | undefined) {
|
||||
if (!value || !value.autocomplete || !this.ql) {
|
||||
return;
|
||||
}
|
||||
this.ql.loadIntrospections(value.autocomplete as unknown as Introspections);
|
||||
if (delta < 0) {
|
||||
return (length + delta) % length;
|
||||
}
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
return ((delta % length) + length) % length;
|
||||
}
|
||||
|
||||
@customElement("ak-search-ql")
|
||||
export class QLSearch extends FormAssociatedElement<string> implements FormAssociated {
|
||||
declare anchorRef: Ref<HTMLTextAreaElement>;
|
||||
declare anchor: HTMLTextAreaElement | null;
|
||||
|
||||
public static styles: CSSResult[] = [
|
||||
PFBase,
|
||||
PFFormControl,
|
||||
PFSearchInput,
|
||||
@@ -66,207 +56,409 @@ export class QLSearch extends AKElement {
|
||||
::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ql.pf-c-form-control {
|
||||
font-family: monospace;
|
||||
--input-height: 2.25em;
|
||||
|
||||
height: var(--input-height);
|
||||
min-height: var(--input-height);
|
||||
max-height: calc(var(--input-height) * 6);
|
||||
resize: vertical;
|
||||
height: 2.25em;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--pf-c-search-input__menu-item--hover--BackgroundColor);
|
||||
}
|
||||
:host([theme="dark"]) .pf-c-search-input__menu {
|
||||
--pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
:host([theme="dark"]) .pf-c-search-input__menu-item {
|
||||
--pf-c-search-input__menu-item--Color: var(--ak-dark-foreground);
|
||||
}
|
||||
:host([theme="dark"]) .pf-c-search-input__menu-item:hover {
|
||||
--pf-c-search-input__menu-item--BackgroundColor: var(--ak-dark-background-lighter);
|
||||
}
|
||||
:host([theme="dark"]) .pf-c-search-input__menu-list-item.selected {
|
||||
--pf-c-search-input__menu-item--hover--BackgroundColor: var(
|
||||
--ak-dark-background-light
|
||||
);
|
||||
}
|
||||
:host([theme="dark"]) .pf-c-search-input__text::before {
|
||||
border: 0;
|
||||
|
||||
:host([theme="dark"]) {
|
||||
.pf-c-search-input__menu {
|
||||
--pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
|
||||
.pf-c-search-input__menu-item {
|
||||
--pf-c-search-input__menu-item--Color: var(--ak-dark-foreground);
|
||||
}
|
||||
|
||||
.pf-c-search-input__menu-item:hover {
|
||||
--pf-c-search-input__menu-item--BackgroundColor: var(
|
||||
--ak-dark-background-lighter
|
||||
);
|
||||
}
|
||||
|
||||
.pf-c-search-input__menu-list-item.selected {
|
||||
--pf-c-search-input__menu-item--hover--BackgroundColor: var(
|
||||
--ak-dark-background-light
|
||||
);
|
||||
}
|
||||
|
||||
.pf-c-search-input__text::before {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-search-input__menu {
|
||||
position: fixed;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
firstUpdated() {
|
||||
if (!this.searchElement) {
|
||||
//#region Properties
|
||||
|
||||
@property({ type: Boolean })
|
||||
public open = false;
|
||||
|
||||
@property({ type: Number, attribute: false })
|
||||
public selectionIndex = -1;
|
||||
|
||||
#value = "";
|
||||
|
||||
@property({ type: String })
|
||||
public get value(): string {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
public set value(value: unknown) {
|
||||
const parsed = typeof value === "string" ? value : "";
|
||||
|
||||
this.setFormValue(parsed.trim(), parsed);
|
||||
this.#value = parsed;
|
||||
|
||||
if (this.anchor) {
|
||||
this.anchor.value = this.#value;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
#menuRef = createRef<HTMLDivElement>();
|
||||
|
||||
#ql: QL | null = null;
|
||||
#ctx: OffscreenCanvasRenderingContext2D | null = null;
|
||||
#letterWidth = -1;
|
||||
#scrollContainer: HTMLElement | null = null;
|
||||
|
||||
public set apiResponse(value: PaginatedResponse<unknown> | undefined) {
|
||||
if (!value?.autocomplete || !this.#ql) {
|
||||
return;
|
||||
}
|
||||
this.ql = new QL({
|
||||
|
||||
this.#ql.loadIntrospections(value.autocomplete as unknown as Introspections);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.internals.ariaAutoComplete = "list";
|
||||
this.internals.role = "combobox";
|
||||
this.internals.ariaHasPopup = "listbox";
|
||||
|
||||
this.#scrollContainer =
|
||||
rootInterface<LitElement>().renderRoot.querySelector("#main-content");
|
||||
|
||||
this.#scrollContainer?.addEventListener("scroll", this.#updateDropdownPosition, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
this.tabIndex = 0;
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.#scrollContainer?.removeEventListener("scroll", this.#updateDropdownPosition);
|
||||
}
|
||||
|
||||
public formStateRestoreCallback(state: string) {
|
||||
this.value = state;
|
||||
}
|
||||
|
||||
public formResetCallback() {
|
||||
this.value = "";
|
||||
}
|
||||
|
||||
public toJSON() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("open")) {
|
||||
this.internals.ariaExpanded = this.open ? "true" : "false";
|
||||
}
|
||||
|
||||
if (changedProperties.has("selectionIndex")) {
|
||||
const id = `suggestion-${this.selectionIndex}`;
|
||||
|
||||
this.setAttribute("aria-activedescendant", this.selectionIndex === -1 ? "" : id);
|
||||
|
||||
this.renderRoot.querySelector(`#${id}`)?.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override firstUpdated() {
|
||||
const textarea = this.anchorRef.value;
|
||||
|
||||
if (!textarea) return;
|
||||
|
||||
this.#ql = new QL({
|
||||
completionEnabled: true,
|
||||
introspections: {
|
||||
current_model: "",
|
||||
models: {},
|
||||
},
|
||||
selector: this.searchElement,
|
||||
selector: textarea,
|
||||
autoResize: false,
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
|
||||
const canvas = new OffscreenCanvas(300, 150);
|
||||
this.#ctx = canvas.getContext("2d");
|
||||
|
||||
if (!this.#ctx) {
|
||||
console.error("authentik/ql: failed to get canvas context");
|
||||
return;
|
||||
}
|
||||
context.font = window.getComputedStyle(this.searchElement).font;
|
||||
this.canvas = context;
|
||||
}
|
||||
|
||||
refreshCompletions() {
|
||||
this.value = this.searchElement?.value;
|
||||
if (!this.ql) {
|
||||
return;
|
||||
}
|
||||
this.ql.generateSuggestions();
|
||||
if (this.ql.suggestions.length < 1 || this.ql.loading) {
|
||||
this.menuOpen = false;
|
||||
return;
|
||||
}
|
||||
this.menuOpen = true;
|
||||
this.updateDropdownPosition();
|
||||
this.requestUpdate();
|
||||
}
|
||||
this.#ctx.font = window.getComputedStyle(textarea).font;
|
||||
|
||||
updateDropdownPosition() {
|
||||
if (!this.searchElement) {
|
||||
return;
|
||||
}
|
||||
const bcr = this.getBoundingClientRect();
|
||||
// We need the width of a letter to measure x; we use a monospaced font but still
|
||||
// check the length for `m` as its the widest ASCII char
|
||||
const metrics = this.canvas?.measureText("m");
|
||||
const letterWidth = Math.ceil(metrics?.width || 0) + 1;
|
||||
const metrics = this.#ctx?.measureText("m");
|
||||
this.#letterWidth = Math.ceil(metrics?.width || 0) + 1;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Completions
|
||||
|
||||
#refreshCompletions = () => {
|
||||
if (this.anchor) {
|
||||
this.value = this.anchor.value;
|
||||
}
|
||||
|
||||
if (!this.#ql) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#ql.generateSuggestions();
|
||||
|
||||
if (this.#ql.suggestions.length < 1 || this.#ql.loading) {
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
requestAnimationFrame(this.#updateDropdownPosition);
|
||||
};
|
||||
|
||||
#updateDropdownPosition = () => {
|
||||
const anchor = this.anchorRef.value;
|
||||
const menu = this.#menuRef.value;
|
||||
|
||||
if (!anchor || !menu) return;
|
||||
|
||||
const bcr = this.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(anchor);
|
||||
|
||||
// Mostly static variables for padding, font line-height and how many
|
||||
const lineHeight = parseInt(window.getComputedStyle(this.searchElement).lineHeight, 10);
|
||||
const paddingTop = parseInt(window.getComputedStyle(this.searchElement).paddingTop, 10);
|
||||
const paddingLeft = parseInt(window.getComputedStyle(this.searchElement).paddingLeft, 10);
|
||||
const paddingRight = parseInt(window.getComputedStyle(this.searchElement).paddingRight, 10);
|
||||
const lineHeight = parseInt(style.lineHeight, 10);
|
||||
const paddingTop = parseInt(style.paddingTop, 10);
|
||||
const paddingLeft = parseInt(style.paddingLeft, 10);
|
||||
const paddingRight = parseInt(style.paddingRight, 10);
|
||||
|
||||
const actualInnerWidth = bcr.width - paddingLeft - paddingRight;
|
||||
|
||||
let relX = 0;
|
||||
let relY = 1;
|
||||
let letterIndex = 0;
|
||||
|
||||
this.searchElement.value.split(" ").some((word, idx) => {
|
||||
for (const word of anchor.value.split(" ")) {
|
||||
letterIndex += word.length;
|
||||
const newRelX = relX + word.length * letterWidth;
|
||||
const newRelX = relX + word.length * this.#letterWidth;
|
||||
|
||||
if (newRelX > actualInnerWidth) {
|
||||
relY += 1;
|
||||
if (letterIndex > this.searchElement!.selectionStart) {
|
||||
|
||||
if (letterIndex > anchor.selectionStart) {
|
||||
relX =
|
||||
letterWidth * word.length -
|
||||
(letterIndex - this.searchElement!.selectionStart) * letterWidth;
|
||||
return true;
|
||||
this.#letterWidth * word.length -
|
||||
(letterIndex - anchor.selectionStart) * this.#letterWidth;
|
||||
|
||||
break;
|
||||
}
|
||||
relX = word.length * letterWidth;
|
||||
|
||||
relX = word.length * this.#letterWidth;
|
||||
} else {
|
||||
relX = newRelX + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.cursorX = bcr.x + paddingLeft + relX;
|
||||
this.cursorY = bcr.y + paddingTop + relY * lineHeight;
|
||||
}
|
||||
const x = bcr.x + paddingLeft + relX;
|
||||
const y = bcr.y + paddingTop + relY * lineHeight;
|
||||
|
||||
Object.assign(menu.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
} satisfies Partial<CSSStyleDeclaration>);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#keydownListener = (event: KeyboardEvent) => {
|
||||
this.#updateDropdownPosition();
|
||||
|
||||
const suggestionsLength = this.#ql?.suggestions.length;
|
||||
|
||||
if (event.key === "Enter" && !this.open && this.form) {
|
||||
const submitEvent = new SubmitEvent("submit", {
|
||||
submitter: this,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(submitEvent);
|
||||
|
||||
onKeyDown(ev: KeyboardEvent) {
|
||||
this.updateDropdownPosition();
|
||||
if (ev.key === "Enter" && ev.metaKey && this.onSearch && this.searchElement) {
|
||||
this.onSearch(this.searchElement?.value);
|
||||
return;
|
||||
}
|
||||
if (!this.menuOpen) return;
|
||||
switch (ev.key) {
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.open && suggestionsLength) {
|
||||
if (this.selectionIndex === -1) {
|
||||
this.selectionIndex = 0;
|
||||
} else {
|
||||
this.selectionIndex = torusIndex(suggestionsLength, this.selectionIndex + 1);
|
||||
}
|
||||
|
||||
this.#refreshCompletions();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectionIndex = 0;
|
||||
this.#refreshCompletions();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.open) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
if (this.ql?.suggestions.length) {
|
||||
if (this.selected === undefined) {
|
||||
this.selected = this.ql?.suggestions.length - 1;
|
||||
} else if (this.selected === 0) {
|
||||
this.selected = undefined;
|
||||
if (suggestionsLength) {
|
||||
if (this.selectionIndex === -1) {
|
||||
this.selectionIndex = suggestionsLength - 1;
|
||||
} else {
|
||||
this.selected -= 1;
|
||||
this.selectionIndex = torusIndex(
|
||||
suggestionsLength,
|
||||
this.selectionIndex - 1,
|
||||
);
|
||||
}
|
||||
this.refreshCompletions();
|
||||
ev.preventDefault();
|
||||
|
||||
this.#refreshCompletions();
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
if (this.ql?.suggestions.length) {
|
||||
if (this.selected === undefined) {
|
||||
this.selected = 0;
|
||||
} else if (this.selected < this.ql?.suggestions.length - 1) {
|
||||
this.selected += 1;
|
||||
} else {
|
||||
this.selected = undefined;
|
||||
}
|
||||
this.refreshCompletions();
|
||||
ev.preventDefault();
|
||||
}
|
||||
break;
|
||||
|
||||
return;
|
||||
|
||||
case "Tab":
|
||||
if (this.selected) {
|
||||
this.ql?.selectCompletion(this.selected);
|
||||
ev.preventDefault();
|
||||
if (this.selectionIndex) {
|
||||
this.#ql?.selectCompletion(this.selectionIndex);
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
|
||||
return;
|
||||
case "Enter":
|
||||
// Technically this is a textarea, due to automatic multi-line feature,
|
||||
// but other than that it should look and behave like a normal input.
|
||||
// So expected behavior when pressing Enter is to submit the form,
|
||||
// not to add a new line.
|
||||
if (this.selected !== undefined) {
|
||||
this.ql?.selectCompletion(this.selected);
|
||||
if (this.selectionIndex !== -1) {
|
||||
this.#ql?.selectCompletion(this.selectionIndex);
|
||||
this.selectionIndex = 0;
|
||||
}
|
||||
ev.preventDefault();
|
||||
break;
|
||||
case "Escape":
|
||||
this.menuOpen = false;
|
||||
break;
|
||||
case "Shift": // Shift
|
||||
case "Control": // Ctrl
|
||||
case "Alt": // Alt
|
||||
case "Meta": // Windows Key or Cmd on Mac
|
||||
// Control keys shouldn't trigger completion popup
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderMenu() {
|
||||
if (!this.menuOpen || !this.ql) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
case "Escape":
|
||||
this.open = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
#blurListener = ({ relatedTarget }: FocusEvent) => {
|
||||
if (relatedTarget instanceof Node && this.renderRoot.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
#focusListener = () => {
|
||||
this.selectionIndex = this.selectionIndex === -1 ? 0 : this.selectionIndex;
|
||||
|
||||
this.#refreshCompletions();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
|
||||
protected renderMenu() {
|
||||
if (!this.open || !this.#ql) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="pf-c-search-input__menu"
|
||||
style="left: ${this.cursorX}px; top: ${this.cursorY}px;"
|
||||
>
|
||||
<ul class="pf-c-search-input__menu-list">
|
||||
${this.ql.suggestions.map((suggestion, idx) => {
|
||||
<div ${ref(this.#menuRef)} class="pf-c-search-input__menu">
|
||||
<ul
|
||||
class="pf-c-search-input__menu-list"
|
||||
role="listbox"
|
||||
id="ql-suggestions"
|
||||
aria-label=${msg("Query suggestions")}
|
||||
>
|
||||
${this.#ql.suggestions.map((suggestion, idx) => {
|
||||
// Cast to string to sooth Lit Analyzer's primitive type rule.
|
||||
const label = `${StripHTMLTrustPolicy.createHTML(suggestion.suggestionText)}`;
|
||||
|
||||
return html`<li
|
||||
class="pf-c-search-input__menu-list-item ${this.selected === idx
|
||||
role="option"
|
||||
id="suggestion-${idx}"
|
||||
aria-selected=${this.selectionIndex === idx ? "true" : "false"}
|
||||
class="pf-c-search-input__menu-list-item ${this.selectionIndex === idx
|
||||
? "selected"
|
||||
: ""}"
|
||||
>
|
||||
<button
|
||||
class="pf-c-search-input__menu-item"
|
||||
type="button"
|
||||
aria-label=${label}
|
||||
@click=${() => {
|
||||
this.ql?.selectCompletion(idx);
|
||||
this.refreshCompletions();
|
||||
this.#ql?.selectCompletion(idx);
|
||||
this.#refreshCompletions();
|
||||
}}
|
||||
>
|
||||
<span class="pf-c-search-input__menu-item-text"
|
||||
>${suggestion.text}</span
|
||||
<span class="pf-c-search-input__menu-item-text pf-m-monospace">
|
||||
${suggestion.text}</span
|
||||
>
|
||||
</button>
|
||||
</li>`;
|
||||
@@ -276,25 +468,33 @@ export class QLSearch extends AKElement {
|
||||
`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
public override render(): TemplateResult {
|
||||
return html`<div class="pf-c-search-input">
|
||||
<div class="pf-c-search-input__bar">
|
||||
<span class="pf-c-search-input__text">
|
||||
<textarea
|
||||
class="pf-c-form-control ql"
|
||||
${ref(this.anchorRef)}
|
||||
class="pf-c-form-control pf-m-monospace ql"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
aria-controls="ql-suggestions"
|
||||
?required=${this.required}
|
||||
placeholder=${msg("Search...")}
|
||||
spellcheck="false"
|
||||
@input=${(ev: InputEvent) => this.refreshCompletions()}
|
||||
@keydown=${this.onKeyDown}
|
||||
@input=${this.#refreshCompletions}
|
||||
@focus=${this.#focusListener}
|
||||
@blur=${this.#blurListener}
|
||||
@keydown=${this.#keydownListener}
|
||||
>
|
||||
${ifDefined(this.value)}</textarea
|
||||
${ifDefined(this.#value)}</textarea
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
${this.renderMenu()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
216
web/src/elements/forms/form-associated-element.ts
Normal file
216
web/src/elements/forms/form-associated-element.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { Jsonifiable } from "type-fest";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { LitElement } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { createRef, Ref } from "lit/directives/ref.js";
|
||||
|
||||
/**
|
||||
* A subset of form associated {@linkcode ElementInternals} properties.
|
||||
*
|
||||
* @see {@linkcode FormAssociatedElement} for usage.
|
||||
*/
|
||||
export interface FormAssociated
|
||||
extends Pick<
|
||||
ElementInternals,
|
||||
| "form"
|
||||
| "validity"
|
||||
| "validationMessage"
|
||||
| "willValidate"
|
||||
| "labels"
|
||||
| "checkValidity"
|
||||
| "reportValidity"
|
||||
> {
|
||||
/**
|
||||
* The name of the input, provided to the form.
|
||||
*/
|
||||
readonly name: string | null;
|
||||
|
||||
/**
|
||||
* The type of the input, provided to the form.
|
||||
*/
|
||||
readonly type: string;
|
||||
|
||||
/**
|
||||
* Whether or not the input is required.
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the input is read-only.
|
||||
*/
|
||||
readonly?: boolean;
|
||||
|
||||
/**
|
||||
* A JSON representation of the value.
|
||||
*/
|
||||
toJSON(): Jsonifiable;
|
||||
}
|
||||
|
||||
export type FormValue = File | string | FormData | null;
|
||||
|
||||
/**
|
||||
* A base element which provides reactive properties and methods for interacting with a parent form.
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals | MDN}
|
||||
*/
|
||||
export abstract class FormAssociatedElement<
|
||||
V extends FormValue = string,
|
||||
T extends Jsonifiable = V extends string ? V : Jsonifiable,
|
||||
S extends FormValue = V,
|
||||
>
|
||||
extends AKElement
|
||||
implements FormAssociated
|
||||
{
|
||||
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
|
||||
|
||||
public static readonly formAssociated = true;
|
||||
|
||||
/**
|
||||
* The internals of the element.
|
||||
*
|
||||
* @protected
|
||||
* @see {@linkcode FormAssociated}
|
||||
*/
|
||||
protected internals = this.attachInternals();
|
||||
|
||||
//#region Reactive Properties
|
||||
|
||||
@property({ type: Boolean })
|
||||
public get required() {
|
||||
return this.internals.ariaRequired === "true";
|
||||
}
|
||||
|
||||
public set required(value: boolean) {
|
||||
this.internals.ariaRequired = value ? "true" : "false";
|
||||
}
|
||||
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public get readOnly() {
|
||||
return this.internals.ariaReadOnly === "true";
|
||||
}
|
||||
|
||||
public set readOnly(value: boolean) {
|
||||
this.internals.ariaReadOnly = value ? "true" : "false";
|
||||
}
|
||||
|
||||
@property({ type: Boolean })
|
||||
public get disabled() {
|
||||
return this.internals.ariaDisabled === "true";
|
||||
}
|
||||
|
||||
public set disabled(value: boolean) {
|
||||
this.internals.ariaDisabled = value ? "true" : "false";
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Aliased Properties
|
||||
|
||||
public get form(): HTMLFormElement | null {
|
||||
return this.internals.form;
|
||||
}
|
||||
|
||||
public get name() {
|
||||
return this.getAttribute("name");
|
||||
}
|
||||
|
||||
public get type() {
|
||||
return this.localName;
|
||||
}
|
||||
|
||||
public get validity() {
|
||||
return this.internals.validity;
|
||||
}
|
||||
|
||||
public get validationMessage() {
|
||||
return this.internals.validationMessage;
|
||||
}
|
||||
|
||||
public get willValidate() {
|
||||
return this.internals.willValidate;
|
||||
}
|
||||
|
||||
public get labels() {
|
||||
return this.internals.labels;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Values
|
||||
|
||||
/**
|
||||
* A reference to an element that is focusable when validation fails.
|
||||
*/
|
||||
protected anchorRef: Ref<HTMLElement>;
|
||||
|
||||
/**
|
||||
* The element that is focusable when validation fails.
|
||||
*/
|
||||
declare protected anchor: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* Set the value of the form.
|
||||
*
|
||||
* @param value The value visible to the form during submission.
|
||||
* @param state The value as provided by the user.
|
||||
*/
|
||||
protected setFormValue(value: V, state?: S) {
|
||||
this.internals.setFormValue(value, state);
|
||||
|
||||
if (this.required) {
|
||||
if (value) {
|
||||
this.internals.setValidity({});
|
||||
} else {
|
||||
this.internals.setValidity(
|
||||
{
|
||||
valueMissing: true,
|
||||
},
|
||||
msg("This field is required."),
|
||||
this.anchorRef.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public abstract toJSON(): T;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Validation
|
||||
|
||||
public checkValidity = this.internals.checkValidity.bind(this.internals);
|
||||
public reportValidity = this.internals.reportValidity.bind(this.internals);
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* Set the validity state of the form.
|
||||
*
|
||||
* @param flags The validity state flags.
|
||||
* @param message The validation message.
|
||||
* @param element The element to set the validity state on.
|
||||
*/
|
||||
protected setValidity(flags: ValidityStateFlags = {}, message?: string, element?: HTMLElement) {
|
||||
this.internals.setValidity(flags, message, element ?? this.anchorRef.value);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this.anchorRef = createRef<HTMLElement>();
|
||||
|
||||
// We define the getter here to allow the base type to be extended,
|
||||
// letting the subclasses define a more accurate HTMLElement type.
|
||||
Object.defineProperty(this, "anchor", {
|
||||
get() {
|
||||
return this.anchorRef.value || null;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
41
web/src/elements/forms/types.d.ts
vendored
Normal file
41
web/src/elements/forms/types.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Type definitions for form-associated elements.
|
||||
*
|
||||
* While these types are part of the HTML standard, they're not yet defined
|
||||
* in the TypeScript standard library, so we define them here.
|
||||
*
|
||||
* @expires 2026-01-01
|
||||
*/
|
||||
|
||||
/**
|
||||
* Callbacks for form-associated elements.
|
||||
*/
|
||||
interface HTMLElement {
|
||||
/**
|
||||
* A callback invoked when the browser autofilling sets a value.
|
||||
*/
|
||||
formStateRestoreCallback?(state: FormValue, mode: "autocomplete"): void;
|
||||
/**
|
||||
* A callback invoked when the browser restores a value from a previous session.
|
||||
*/
|
||||
formStateRestoreCallback?(state: FormValue, mode: "restore"): void;
|
||||
/**
|
||||
* A callback invoked when the browser restores a value from a previous session.
|
||||
*/
|
||||
formStateRestoreCallback?(state: FormValue, mode: "restore" | "autocomplete"): void;
|
||||
|
||||
/**
|
||||
* A callback that is invoked when the form is reset.
|
||||
*/
|
||||
formResetCallback?(): void;
|
||||
|
||||
/**
|
||||
* A callback that is invoked when the element's disabled state changes.
|
||||
*/
|
||||
formDisabledCallback?(disabled: boolean): void;
|
||||
|
||||
/**
|
||||
* A callback that is invoked when the element is associated with a form.
|
||||
*/
|
||||
formAssociatedCallback?(form: HTMLFormElement): void;
|
||||
}
|
||||
@@ -124,8 +124,8 @@ export abstract class Table<T extends object>
|
||||
@property({ type: String })
|
||||
public order?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public search: string = "";
|
||||
@property({ type: String, attribute: false })
|
||||
public search?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public checkbox = false;
|
||||
@@ -547,11 +547,11 @@ export abstract class Table<T extends object>
|
||||
return html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
|
||||
<ak-table-search
|
||||
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
|
||||
value=${ifDefined(this.search)}
|
||||
.defaultValue=${this.search}
|
||||
label=${ifDefined(this.searchLabel)}
|
||||
placeholder=${ifDefined(this.searchPlaceholder)}
|
||||
.onSearch=${this.#searchListener}
|
||||
?supportsQL=${this.supportsQL}
|
||||
.supportsQL=${this.supportsQL}
|
||||
.apiResponse=${this.data}
|
||||
>
|
||||
</ak-table-search>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
@@ -16,17 +17,23 @@ import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
@customElement("ak-table-search")
|
||||
export class TableSearch extends WithLicenseSummary(AKElement) {
|
||||
@property()
|
||||
public value?: string;
|
||||
export class TableSearchForm extends WithLicenseSummary(AKElement) {
|
||||
@property({ type: String, reflect: false })
|
||||
public defaultValue?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
@property({ type: String })
|
||||
public label = msg("Table Search");
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder = msg("Search...");
|
||||
|
||||
@property({ attribute: false })
|
||||
public supportsQL: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public apiResponse?: PaginatedResponse<unknown>;
|
||||
|
||||
@property()
|
||||
@property({ attribute: false })
|
||||
public onSearch?: (value: string) => void;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
@@ -45,25 +52,26 @@ export class TableSearch extends WithLicenseSummary(AKElement) {
|
||||
`,
|
||||
];
|
||||
|
||||
public reset = () => {
|
||||
if (!this.onSearch) return;
|
||||
this.value = "";
|
||||
this.onSearch("");
|
||||
#formRef = createRef<HTMLFormElement>();
|
||||
|
||||
public reset = (): void => {
|
||||
this.#formRef.value?.reset();
|
||||
|
||||
this.onSearch?.("");
|
||||
};
|
||||
|
||||
#submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.onSearch) return;
|
||||
const form = this.#formRef.value;
|
||||
|
||||
if (!form || !this.onSearch) return;
|
||||
|
||||
form.reportValidity();
|
||||
|
||||
const form = event.target as HTMLFormElement;
|
||||
const data = new FormData(form);
|
||||
|
||||
const value = data.get("search")?.toString().trim();
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const value = data.get("search")?.toString() ?? "";
|
||||
|
||||
this.onSearch(value);
|
||||
};
|
||||
@@ -71,27 +79,31 @@ export class TableSearch extends WithLicenseSummary(AKElement) {
|
||||
renderInput(): TemplateResult {
|
||||
if (this.supportsQL && this.hasEnterpriseLicense) {
|
||||
return html`<ak-search-ql
|
||||
.apiResponse=${this.apiResponse}
|
||||
.value=${this.value}
|
||||
.onSearch=${(value: string) => {
|
||||
if (!this.onSearch) return;
|
||||
this.onSearch(value);
|
||||
}}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
name="search"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
value=${ifDefined(this.defaultValue)}
|
||||
.apiResponse=${this.apiResponse}
|
||||
></ak-search-ql>`;
|
||||
}
|
||||
|
||||
return html`<input
|
||||
class="pf-c-form-control"
|
||||
aria-label=${ifDefined(this.label)}
|
||||
name="search"
|
||||
type="search"
|
||||
placeholder=${msg("Search...")}
|
||||
value="${ifDefined(this.value)}"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
value=${ifDefined(this.defaultValue)}
|
||||
class="pf-c-form-control"
|
||||
/>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form class="pf-c-input-group" method="get" @submit=${this.#submitListener}>
|
||||
return html`<form
|
||||
${ref(this.#formRef)}
|
||||
class="pf-c-input-group"
|
||||
@submit=${this.#submitListener}
|
||||
>
|
||||
${this.renderInput()}
|
||||
<button
|
||||
aria-label=${msg("Clear search")}
|
||||
@@ -110,6 +122,6 @@ export class TableSearch extends WithLicenseSummary(AKElement) {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-table-search": TableSearch;
|
||||
"ak-table-search": TableSearchForm;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +33,10 @@ export class FlowCard extends AKElement {
|
||||
PFLogin,
|
||||
PFTitle,
|
||||
css`
|
||||
slot[name="footer"],
|
||||
slot[name="footer-band"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
flex-basis: 100%;
|
||||
.pf-c-login__main-footer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
slot[name="footer-band"] {
|
||||
text-align: center;
|
||||
background-color: var(--pf-c-login__main-footer-band--BackgroundColor);
|
||||
|
||||
@@ -78,6 +78,13 @@ After the installation is complete, access authentik at `https://<ingress-host-n
|
||||
You will get `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Make sure you use the complete url (`http://<ingress-host-name>/if/flow/initial-setup/`) including the trailing forward slash.
|
||||
:::
|
||||
|
||||
### PostgreSQL production setup
|
||||
|
||||
We recommend using another installation method for PostgreSQL than the one provided that is only intended for demonstration and testing purposes. We recommend the following operators:
|
||||
|
||||
- [CloudNativePG](https://github.com/cloudnative-pg/cloudnative-pg)
|
||||
- [Zalando Postgres Operator](https://github.com/zalando/postgres-operator)
|
||||
|
||||
### Optional step: Configure global email credentials
|
||||
|
||||
It is recommended to configure global email credentials as well. These are used by authentik to notify you about alerts and configuration issues. Additionally, they can be utilized by [Email stages](../../add-secure-apps/flows-stages/stages/email/index.mdx) to send verification and recovery emails.
|
||||
|
||||
@@ -3,12 +3,6 @@ title: Release 2025.8
|
||||
slug: "/releases/2025.8"
|
||||
---
|
||||
|
||||
:::::note
|
||||
2025.8 has not been released yet! We're publishing these release notes as a preview of what's to come, and for our awesome beta testers trying out release candidates.
|
||||
|
||||
To try out the release candidate, replace your Docker image tag with the latest release candidate number, such as 2025.8.0-rc1. You can find the latest one in [the latest releases on GitHub](https://github.com/goauthentik/authentik/releases). If you don't find any, it means we haven't released one yet.
|
||||
:::::
|
||||
|
||||
## Highlights
|
||||
|
||||
- **OAuth2/OpenID Connect back-channel logout**: :ak-preview A server-to-server notification mechanism that allows authentik to notify OAuth2/OpenID providers whenever a user's session is terminated.
|
||||
@@ -17,7 +11,7 @@ To try out the release candidate, replace your Docker image tag with the latest
|
||||
|
||||

|
||||
|
||||
- **Advanced search**: :ak-enterprise Search for users and events with custom query language to filter on their properties and attributes.
|
||||
- **Advanced search**: :ak-enterprise Search for [users](../../users-sources/user/user_basic_operations.md#tell-me-more) and [event logs](../../sys-mgmt/events/logging-events.md#tell-me-more) with custom query language to filter on their properties and attributes.
|
||||
|
||||
- **Email stage rate limiting**: The email stage can now be configured to set a maximum number of emails that can be sent within a specified time period.
|
||||
|
||||
@@ -118,6 +112,26 @@ Instead, the following metrics are now available:
|
||||
- `authentik_tasks_delayed_in_progress`
|
||||
- `authentik_tasks_duration_milliseconds`
|
||||
|
||||
### Prometheus metrics
|
||||
|
||||
The tasks metrics are no longer exposed by the server, but by the worker. For Helm chart users, add the following values to enable a `ServiceMonitor` to scrape those metrics:
|
||||
|
||||
```yaml
|
||||
worker:
|
||||
metrics:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
### Helm chart changes
|
||||
|
||||
Due to [Bitnami upcoming changes](https://github.com/bitnami/containers/issues/83267) to availability of their container images, the Helm chart default values have been updated to instead use [docker.io/library/postgres](https://hub.docker.com/_/postgres) and [docker.io/library/redis](https://hub.docker.com/_/redis). If you are setting custom values for either PostgreSQL or Redis, please review the [associated Helm chart changes](https://github.com/goauthentik/helm/pull/385) to update your values.
|
||||
|
||||
Redis has also been updated from 8.0 to 8.2.
|
||||
|
||||
From this point on, we recommend using the bundled PostgreSQL dependency for demonstration and test purposes only. See our [installation documentation](../../install-config/install/kubernetes.md) for alternatives to run PostgreSQL in a production environment.
|
||||
|
||||
## New features and improvements
|
||||
|
||||
- **LDAP Provider improvements**:
|
||||
@@ -139,6 +153,7 @@ An integration is a how authentik connects to third-party applications, director
|
||||
- [Papra](https://integrations.goauthentik.io/documentation/papra/)
|
||||
- [Planka](https://integrations.goauthentik.io/chat-communication-collaboration/planka/)
|
||||
- [Seafile](https://integrations.goauthentik.io/media/seafile/)
|
||||
- [Vaultwarden](https://integrations.goauthentik.io/security/vaultwarden/)
|
||||
- [Zoho](https://integrations.goauthentik.io/platforms/zoho/)
|
||||
|
||||
## Upgrading
|
||||
@@ -180,8 +195,10 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.8
|
||||
- blueprints: add JSON tag to parse JSON from string (#15235)
|
||||
- blueprints: add section support for organisation (#15045)
|
||||
- blueprints: sort schema items (#15022)
|
||||
- brands: revert sort matched brand by match length (revert #15413) (cherry-pick #16233) (#16235)
|
||||
- brands: sort matched brand by match length (#15413)
|
||||
- core, providers/ldap: add parent/child groups to api and ldap results (#14974)
|
||||
- core: Add email template selector (cherry-pick #16170) (#16225)
|
||||
- core: Prevent application creation with reserved slugs (#15930)
|
||||
- core: add updated_at field to user (#15571)
|
||||
- core: better API validation for JSON fields (#15236)
|
||||
@@ -215,6 +232,7 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.8
|
||||
- packages/django-dramatiq-postgres: broker: remember previously fetched notifies (#16128)
|
||||
- packages/django-dramatiq-postgres: fix typo (#15932)
|
||||
- packages/django-dramatiq-postgres: run worker in the same base process, use structlog (#16061)
|
||||
- policies/password: Fix amount_uppercase in password policy check (cherry-pick #16197) (#16228)
|
||||
- policies/reputation: fix updated for reputation not updating (#15782)
|
||||
- policies: Optimize policy checking for static bindings (#14957)
|
||||
- policies: buffered policy access view for concurrent authorization attempts when unauthenticated (#15034)
|
||||
@@ -234,6 +252,9 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.8
|
||||
- root: add system check for database encoding (#15186)
|
||||
- root: enhance custom middleware experience (#15919)
|
||||
- root: extract custom setup code (#15150)
|
||||
- root: fix custom packages installation in docker (cherry-pick #16150) (#16151)
|
||||
- root: fix custom packages installation in docker (cherry-pick #16157) (#16158)
|
||||
- root: fix missing uv run in makefile (#16146)
|
||||
- root: fix some cases of invalid data triggering exceptions (#14799)
|
||||
- root: monitoring: force db connection reload before healthcheck (#9970)
|
||||
- root: remove /if/help (#14929)
|
||||
@@ -253,6 +274,7 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.8
|
||||
- stages/prompt: fix list policy for prompt validation failing with multiple policies (#15522)
|
||||
- stages/user_login: unknown device (#14459)
|
||||
- tasks/schedules: fix IntegrityError on schedule update (#15871)
|
||||
- tasks: add sentry dramatiq integration (cherry-pick #16167) (#16183)
|
||||
- tasks: fix rel_obj being removed when task is retried (#15862)
|
||||
- tests/e2e: WebAuthn E2E tests (#14461)
|
||||
- web/a11y -- ak-form-group (#15688)
|
||||
@@ -262,10 +284,12 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.8
|
||||
- web/a11y: Form Inputs (#15878)
|
||||
- web/a11y: License notice ARIA attributes. (#15872)
|
||||
- web/a11y: Navigation Banner (#15880)
|
||||
- web/a11y: QL Search Input (cherry-pick #16198) (#16229)
|
||||
- web/a11y: Tables & Modals (#15877)
|
||||
- web/admin: Text and Textarea Fields that "hide" their contents until prompted (#15024)
|
||||
- web/admin: adopt ak-hidden-text (#15042)
|
||||
- web/admin: fix language in certificate import (#14953)
|
||||
- web/admin: fix settings saving (cherry-pick #16184) (#16187)
|
||||
- web/admin: fix variable name (#15934)
|
||||
- web/admin: hide webhook URL by default (#15136)
|
||||
- web/admin: improve admin UI for tasks slightly (#15829)
|
||||
@@ -298,6 +322,7 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2025.8
|
||||
- web: Clean up file methods. (#15479)
|
||||
- web: Consistent use of static styles (#15510)
|
||||
- web: Disable autocomplete. (#15551)
|
||||
- web: Fix ak-flow-card footer alignment. (cherry-pick #16236) (#16238)
|
||||
- web: Fix cursor using pointer in modals. (#16009)
|
||||
- web: Fix dangling div. (#15478)
|
||||
- web: Fix form captcha submission (#15482)
|
||||
@@ -547,6 +572,10 @@ Changed response : **200 OK**
|
||||
- Changed content type : `application/json`
|
||||
- Changed property `attributes` (object -> object)
|
||||
|
||||
##### `POST` /core/users/{id}/recovery/
|
||||
|
||||
##### `POST` /core/users/{id}/recovery_email/
|
||||
|
||||
##### `GET` /events/events/volume/
|
||||
|
||||
###### Parameters:
|
||||
@@ -1915,6 +1944,55 @@ Changed response : **200 OK**
|
||||
|
||||
* Deleted property `group_obj` (object)
|
||||
|
||||
##### `GET` /events/transports/{uuid}/
|
||||
|
||||
###### Return Type:
|
||||
|
||||
Changed response : **200 OK**
|
||||
|
||||
- Changed content type : `application/json`
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
##### `PUT` /events/transports/{uuid}/
|
||||
|
||||
###### Request:
|
||||
|
||||
Changed content type : `application/json`
|
||||
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
###### Return Type:
|
||||
|
||||
Changed response : **200 OK**
|
||||
|
||||
- Changed content type : `application/json`
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
##### `PATCH` /events/transports/{uuid}/
|
||||
|
||||
###### Request:
|
||||
|
||||
Changed content type : `application/json`
|
||||
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
###### Return Type:
|
||||
|
||||
Changed response : **200 OK**
|
||||
|
||||
- Changed content type : `application/json`
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
##### `POST` /managed/blueprints/
|
||||
|
||||
###### Request:
|
||||
@@ -3676,6 +3754,25 @@ Changed response : **200 OK**
|
||||
|
||||
- Changed property `brand` (object -> object)
|
||||
|
||||
##### `POST` /events/transports/
|
||||
|
||||
###### Request:
|
||||
|
||||
Changed content type : `application/json`
|
||||
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
###### Return Type:
|
||||
|
||||
Changed response : **201 Created**
|
||||
|
||||
- Changed content type : `application/json`
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
##### `GET` /events/transports/
|
||||
|
||||
###### Return Type:
|
||||
@@ -3688,6 +3785,13 @@ Changed response : **200 OK**
|
||||
- `autocomplete`
|
||||
* Added property `autocomplete` (object)
|
||||
|
||||
* Changed property `results` (array)
|
||||
|
||||
Changed items (object): > NotificationTransport Serializer
|
||||
- Added property `email_subject_prefix` (string)
|
||||
|
||||
- Added property `email_template` (string)
|
||||
|
||||
##### `GET` /flows/instances/
|
||||
|
||||
###### Return Type:
|
||||
|
||||
@@ -26,8 +26,36 @@ You can view audit details in the following areas of the authentik Admin interfa
|
||||
|
||||
- **Admin interface > Events > Logs**: In the event list, click the arrow toggle next to the event you want to view.
|
||||
|
||||
## Viewing events in maps and charts :ak-enterprise :ak-version[2025.8]
|
||||
## Viewing events in maps and charts :ak-enterprise
|
||||
|
||||
With the enterprise version, you can view recent events on both a world map view with pinpoints indicating where each event occurred and also a color-coded chart that highlights event types and volume.
|
||||
|
||||

|
||||
|
||||
## Advanced queries for event logs:ak-enterprise {#tell-me-more}
|
||||
|
||||
You can construct advanced queries to find specific event logs. In the Admin interface, navigate to **Events > Logs**, and then use the auto-complete in the **Search** field or enter your own queries to return results with greater specificity.
|
||||
|
||||
- **Field**: `action`, `event_uuid`, `app`, `client_ip`, `user`, `brand`, `context`, `created`
|
||||
|
||||
- **Operators**: `=`, `!=`, `~`, `!~`, `startswith`, `not startswith`, `endswidth`, `not endswith`, `in`, `not in`
|
||||
|
||||
- **Values**: `True`, `False`, `None`, and more
|
||||
|
||||
- **Example queries**:
|
||||
- search event by application name: `app startswith "N"`
|
||||
- search event by action: `action = "login"`
|
||||
- search event by authorized application context: `authorized_application.name = "My app"`
|
||||
- search event by country: `context.geo.country = "Germany"`
|
||||
- search event by IP address: `client_ip = "10.0.0.1"`
|
||||
- search event by brand: `brand.name = "my brand"`
|
||||
- search event by user: `user.username in ["ana", "akadmin"]`
|
||||
|
||||
For more examples, refer to the list of [Event actions](./event-actions.md) and the related examples for each type of event.
|
||||
|
||||
:::info
|
||||
|
||||
1. To dismiss the drop-down menu option, click **ESC**.
|
||||
2. If the list of operators does not appear in a drop-down menu you will need to manually enter it.
|
||||
3. For queries that include `user`, `brand`, or `context` you need to use a compound term such as `user.username` or `brand.name`.
|
||||
:::
|
||||
|
||||
@@ -2,43 +2,43 @@
|
||||
title: S3 storage setup
|
||||
---
|
||||
|
||||
### Preparation
|
||||
## Preparation
|
||||
|
||||
First, create a user on your S3 storage provider and get access credentials for S3, hereafter referred as `access_key` and `secret_key`.
|
||||
First, create a user on your S3 storage provider and get access credentials (hereafter referred to as `access_key` and `secret_key`).
|
||||
|
||||
You will also need to know which endpoint authentik is going to use to access the S3 API, hereafter referred as `https://s3.provider`.
|
||||
You will also need the S3 API endpoint that authentik will use (hereafter referred to as `https://s3.provider`). When using AWS S3, there’s no need to set the endpoint, but for S3-compatible services like Azure Blob Storage or Cloudflare R2, use the provider's endpoint URL.
|
||||
|
||||
The bucket in which authentik is going to store files is going to be called `authentik-media`. You may need to change this name depending on your S3 provider limitations. Also, we are suffixing the bucket name with `-media` as authentik currently only stores media files, but may use other buckets in the future.
|
||||
Create or pick a bucket for authentik media, for example `authentik-media`. Adjust the name to your provider’s bucket naming rules. We suffix with `-media` as authentik currently only stores media files (icons, etc.).
|
||||
|
||||
The domain used to access authentik is going to be referred to as `authentik.company`.
|
||||
The domain you use to access authentik is referred to as `authentik.company` in the examples below.
|
||||
|
||||
You will also need the AWS CLI.
|
||||
You will also need the AWS CLI available locally.
|
||||
|
||||
### S3 configuration
|
||||
## S3 configuration
|
||||
|
||||
#### Bucket creation
|
||||
### Bucket creation
|
||||
|
||||
Create the bucket in which authentik is going to store files:
|
||||
Create the bucket that authentik will use for media files:
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider create-bucket --bucket=authentik-media --acl=private
|
||||
```
|
||||
|
||||
If using AWS S3, you can omit the `--endpoint-url` option, but may need to specify the `--region` option. If using Google Cloud Storage, refer to its documentation on how to create buckets.
|
||||
If using AWS S3, you can omit `--endpoint-url`, but you may need to specify `--region`. Some regions require `--create-bucket-configuration LocationConstraint=<region>`.
|
||||
|
||||
The bucket ACL is set to private, although that is not strictly necessary, as an ACL associated with each object stored in the bucket will be private as well.
|
||||
The bucket ACL is set to private. Depending on your provider you can alternatively disable ACLs and rely on bucket policies.
|
||||
|
||||
#### CORS policy
|
||||
### CORS policy
|
||||
|
||||
Next, associate a CORS policy to the bucket to allow the authentik web interface to show images stored in the bucket.
|
||||
Apply a CORS policy to the bucket, allowing the authentik web interface to access images directly.
|
||||
|
||||
First, save the following file locally as `cors.json`:
|
||||
Save the following as `cors.json` (use your deployment’s origin; include scheme and port if non‑standard):
|
||||
|
||||
```json
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["authentik.company"],
|
||||
"AllowedOrigins": ["https://authentik.company"],
|
||||
"AllowedHeaders": ["Authorization"],
|
||||
"AllowedMethods": ["GET"],
|
||||
"MaxAgeSeconds": 3000
|
||||
@@ -47,9 +47,9 @@ First, save the following file locally as `cors.json`:
|
||||
}
|
||||
```
|
||||
|
||||
If authentik is accessed from multiple domains, you can add them to the `AllowedOrigins` list.
|
||||
If authentik is accessed from multiple domains, include each one in `AllowedOrigins`.
|
||||
|
||||
Apply that policy to the bucket:
|
||||
Apply the policy to the bucket:
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider put-bucket-cors --bucket=authentik-media --cors-configuration=file://cors.json
|
||||
@@ -66,39 +66,51 @@ AUTHENTIK_STORAGE__MEDIA__S3__SECRET_KEY=secret_key
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__BUCKET_NAME=authentik-media
|
||||
```
|
||||
|
||||
If you are using AWS S3 as your S3 provider, add the following:
|
||||
If you are using AWS S3, add:
|
||||
|
||||
```env
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__REGION=us-east-1 # Use the region of the bucket
|
||||
```
|
||||
|
||||
If you are not using AWS S3 as your S3 provider, add the following:
|
||||
If you are using an S3‑compatible provider (non‑AWS), add:
|
||||
|
||||
```env
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT=https://s3.provider
|
||||
AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN=s3.provider/authentik-media
|
||||
```
|
||||
|
||||
The `ENDPOINT` setting specifies how authentik talks to the S3 provider.
|
||||
The `AUTHENTIK_STORAGE__MEDIA__S3__ENDPOINT` setting controls how authentik communicates with the S3 provider. When set, it overrides region/`USE_SSL`.
|
||||
|
||||
The `CUSTOM_DOMAIN` setting specifies how URLs are constructed to be shown on the web interface. For example, an object stored at `application-icons/application.png` with a `CUSTOM__DOMAIN` setting of `s3.provider/authentik-media` will result in a URL of `https://s3.provider/authentik-media/application-icons/application.png`. You can also use subdomains for your buckets depending on what your S3 provider offers: `authentik-media.s3.provider`. Whether HTTPS is used is controlled by `AUTHENTIK_STORAGE__MEDIA__S3__SECURE_URLS`, which defaults to true.
|
||||
The `AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN` setting controls how media URLs are built for the web interface. It must include the bucket name and must not include a scheme.
|
||||
|
||||
For more control over settings, refer to the [configuration reference](../../install-config/configuration/configuration.mdx#media-storage-settings)
|
||||
For a path-style domain, set `AUTHENTIK_STORAGE__MEDIA__S3__CUSTOM_DOMAIN=s3.provider/authentik-media`. The object `application-icons/application.png` will be available at `https://s3.provider/authentik-media/application-icons/application.png`.
|
||||
|
||||
### Migrating between storage backends
|
||||
Whether URLs use HTTPS is controlled by `AUTHENTIK_STORAGE__MEDIA__S3__SECURE_URLS` (defaults to `true`). Depending on your provider, you can also use a virtual hosted-style domain such as `authentik-media.s3.provider`.
|
||||
|
||||
The following section assumes that the local storage path is `/media` and the bucket name is `authentik-media`. It also assumes you have a working `aws` CLI that can interact with the bucket.
|
||||
:::info
|
||||
You can omit `ACCESS_KEY` and `SECRET_KEY` when using AWS SDK authentication (instance roles or profiles). See `AUTHENTIK_STORAGE__MEDIA__S3__SESSION_PROFILE` and related options in the configuration reference](../../install-config/configuration/configuration.mdx#media-storage-settings).
|
||||
:::
|
||||
|
||||
#### From file to s3
|
||||
For more options (including `AUTHENTIK_STORAGE__MEDIA__S3__USE_SSL`, session profiles, and security tokens), see the [configuration reference](../../install-config/configuration/configuration.mdx#media-storage-settings).
|
||||
|
||||
Follow the setup steps above, and then migrate the files from your local directory to s3:
|
||||
## Migrating between storage backends
|
||||
|
||||
The following assumes the local storage path is `/media` and the bucket is `authentik-media`. Ensure your `aws` CLI is configured to talk to your provider (add `--endpoint-url` or `--region` as needed).
|
||||
|
||||
### From file to s3
|
||||
|
||||
Follow the setup steps above, then sync files from the local directory to S3 (to the bucket root):
|
||||
|
||||
```bash
|
||||
aws s3 sync /media s3://authentik-media/media
|
||||
aws s3 sync /media s3://authentik-media/
|
||||
# For non-AWS providers, include the endpoint:
|
||||
# aws --endpoint-url=https://s3.provider s3 sync /media s3://authentik-media/
|
||||
```
|
||||
|
||||
#### From s3 to file
|
||||
### From s3 to file
|
||||
|
||||
```bash
|
||||
aws s3 sync s3://authentik-media/media /media
|
||||
aws s3 sync s3://authentik-media/ /media
|
||||
# For non-AWS providers:
|
||||
# aws --endpoint-url=https://s3.provider s3 sync s3://authentik-media/ /media
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ The following topics are for the basic management of users: how to create, modif
|
||||
|
||||
[Policies](../../customize/policies/index.md) can be used to further manage how users are authenticated. For example, by default authentik does not require email addresses be unique, but you can use a policy to [enforce unique email addresses](../../customize/policies/expression/unique_email.md).
|
||||
|
||||
### Create a user
|
||||
## Create a user
|
||||
|
||||
> If you want to automate user creation, you can do that either by [invitations](./invitations.md), [`user_write` stage](../../add-secure-apps/flows-stages/stages/user_write.md), or [using the API](/api/reference/core-users-create).
|
||||
|
||||
@@ -33,7 +33,29 @@ You should see a confirmation pop-up on the top-right of the screen that the use
|
||||
To create a super-user, you need to add the user to a group that has super-user permissions. For more information, refer to [Create a Group](../groups/manage_groups.mdx#create-a-group).
|
||||
:::
|
||||
|
||||
### View user details
|
||||
## Advanced queries for users:ak-enterprise {#tell-me-more}
|
||||
|
||||
You can create advanced queries to locate specific users within the list shown under **Directory** > **Users** in the Admin interface. Use the auto-complete in the **Search** field or enter your own queries to return results with greater specificity.
|
||||
|
||||
- **Field**: `username`, `path`, `name`, `email`, `path`, `is_active`, `type`, `attributes`
|
||||
|
||||
- **Operators**: `=`, `!=`, `~`, `!~`, `startswith`, `not startswith`, `endswidth`, `not endswith`, `in`, `not in`
|
||||
|
||||
- **Values**: `True`, `False`, `None`, and more
|
||||
|
||||
- **Example queries**:
|
||||
- search user by status: `is_active = False`
|
||||
- search user by username: `username = "bob"`
|
||||
- search user by email address: `email = "bob@authentik.company"`
|
||||
- search user by attribute: `attribute.my_custom_attribute = "foo"`
|
||||
|
||||
:::info
|
||||
|
||||
1. To dismiss the drop-down menu option, click **ESC**.
|
||||
2. If the list of operators does not appear in a drop-down menu you will need to manually enter it.
|
||||
:::
|
||||
|
||||
## View user details
|
||||
|
||||
In the **Directory > Users** menu of the Admin interface, you can browse all the users in your authentik instance.
|
||||
|
||||
@@ -58,7 +80,7 @@ After the creation of the user, you can edit any parameter defined during the cr
|
||||
|
||||
To modify a user object, go to **Directory > Users**, and click the edit icon beside the name. You can also go into [user details](#view-user-details), and click **Edit**.
|
||||
|
||||
### Assign, modify, or remove permissions for a user
|
||||
## Assign, modify, or remove permissions for a user
|
||||
|
||||
You can grant a user specific global or object-level permissions. Alternatively, you can add a user to a group that has the appropriate permissions, and the user inherits all of the group's permissions.
|
||||
|
||||
@@ -86,7 +108,7 @@ This option is only available if a default recovery flow was configured for the
|
||||
|
||||
A pop-up will appear on your browser with the link for you to copy and to send to the user.
|
||||
|
||||
### Email them a recovery link
|
||||
### Email a recovery link
|
||||
|
||||
:::info
|
||||
This option is only available if a default recovery flow was configured for the currently active brand and if the configured flow has an [Email Stage](../../add-secure-apps/flows-stages/stages/email/index.mdx) bound to it.
|
||||
@@ -100,7 +122,7 @@ You can send a link with the URL for the user to reset their password via Email.
|
||||
|
||||
If the user does not receive the email, check if the mail server parameters [are properly configured](../../troubleshooting/emails.md).
|
||||
|
||||
### Reset the password for the user
|
||||
## Reset the password for the user
|
||||
|
||||
As an Admin, you can simply reset the password for the user.
|
||||
|
||||
@@ -110,14 +132,14 @@ As an Admin, you can simply reset the password for the user.
|
||||
|
||||
## Deactivate or Delete user
|
||||
|
||||
#### To deactivate a user:
|
||||
### To deactivate a user:
|
||||
|
||||
1. Go into the user list or detail, and click **Deactivate**.
|
||||
2. Review the changes and click **Update**.
|
||||
|
||||
The active sessions are revoked and the authentication of the user blocked. You can reactivate the account by following the same procedure.
|
||||
|
||||
#### To delete a user:
|
||||
### To delete a user:
|
||||
|
||||
:::caution
|
||||
This deletion is not reversible, so be sure you do not need to recover any identity data of the user.
|
||||
|
||||
Reference in New Issue
Block a user