Compare commits

...

24 Commits

Author SHA1 Message Date
authentik-automation[bot]
9f5125cf6b release: 2025.8.0-rc7 2025-08-18 18:44:42 +00:00
Marc 'risson' Schmitt
a84411363e web: Fix ak-flow-card footer alignment. (cherry-pick #16236) (#16238)
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Fix ak-flow-card footer alignment. (#16236)
2025-08-18 20:15:21 +02:00
Marc 'risson' Schmitt
9f3bb0210b brands: revert sort matched brand by match length (revert #15413) (cherry-pick #16233) (#16235) 2025-08-18 19:28:19 +02:00
Marc 'risson' Schmitt
d1271502ef web: bump API Client version (cherry-pick #16203) (#16226)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-08-18 16:42:35 +02:00
Marc 'risson' Schmitt
d1065b2d49 policies/password: Fix amount_uppercase in password policy check (cherry-pick #16197) (#16228)
Co-authored-by: M-Slanec <mcslanec@gmail.com>
Co-authored-by: Matthew Slanec <matthewslanec@Matthews-MacBook-Pro.local>
Fix amount_uppercase in password policy check (#16197)
2025-08-18 16:42:26 +02:00
Marc 'risson' Schmitt
aa19227e30 web/a11y: QL Search Input (cherry-pick #16198) (#16229)
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2025-08-18 16:41:50 +02:00
Marc 'risson' Schmitt
d7a2861bbe stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (cherry-pick #16196) (#16227)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-08-18 16:41:33 +02:00
Marc 'risson' Schmitt
5aae9c9afa core: Add email template selector (cherry-pick #16170) (#16225)
Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-08-18 16:41:22 +02:00
Tana M Berry
93cb48c928 website/docs: add content about new Advanced Query searches (cherry-pick #16019) (#16188)
Co-authored-by: Tana M Berry <tana@goauthentik.io>
2025-08-14 18:32:05 +02:00
Marc 'risson' Schmitt
55f7f93a24 web/admin: fix settings saving (cherry-pick #16184) (#16187)
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-08-14 13:02:30 +00:00
authentik-automation[bot]
5a608a4235 release: 2025.8.0-rc6 2025-08-14 11:29:06 +00:00
Marc 'risson' Schmitt
77d023758f tasks: add sentry dramatiq integration (cherry-pick #16167) (#16183)
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-08-14 13:05:59 +02:00
Marc 'risson' Schmitt
f9edafd374 ci: release tag: fix missing env variables (cherry-pick #16172) (#16173) 2025-08-13 21:31:01 +02:00
Marc 'risson' Schmitt
d94219eb0e ci: release: consolidation bump version and on tag (cherry-pick #16164) (#16169)
Co-authored-by: Dominic R <dominic@sdko.org>
2025-08-13 18:22:11 +02:00
Marc 'risson' Schmitt
0871aa2cf3 ci: fix docker hub credentials (cherry-pick #16165) (#16166) 2025-08-13 15:20:03 +02:00
authentik-automation[bot]
e0fe99d0b8 release: 2025.8.0-rc5 2025-08-13 00:21:34 +00:00
Marc 'risson' Schmitt
c1acf53585 root: fix custom packages installation in docker (cherry-pick #16157) (#16158) 2025-08-13 02:08:56 +02:00
authentik-automation[bot]
baf4eed0d9 release: 2025.8.0-rc4 2025-08-12 22:26:57 +00:00
Marc 'risson' Schmitt
60e1192a7a ci: release publish: fix missing permissions (cherry-pick #16155) (#16156) 2025-08-13 00:20:52 +02:00
authentik-automation[bot]
2608e02d6e release: 2025.8.0-rc3 2025-08-12 21:49:51 +00:00
Marc 'risson' Schmitt
0a5928fbcb ci: docker push: fix version missing dash (cherry-pick #16153) (#16154) 2025-08-12 23:44:01 +02:00
authentik-automation[bot]
7b99b02b4a release: 2025.8.0-rc2 2025-08-12 21:12:07 +00:00
Marc 'risson' Schmitt
dfeadc9ebe root: fix custom packages installation in docker (cherry-pick #16150) (#16151) 2025-08-12 23:08:40 +02:00
authentik-automation[bot]
7ff64fbd09 release: 2025.8.0-rc1 2025-08-12 20:31:40 +00:00
48 changed files with 1247 additions and 524 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>"

View File

@@ -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

View File

@@ -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>"

View File

@@ -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/

View File

@@ -3,7 +3,7 @@
from functools import lru_cache
from os import environ
VERSION = "2025.8.0-rc1"
VERSION = "2025.8.0-rc7"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@@ -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()

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -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"),
),
]

View File

@@ -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)

View File

@@ -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"]])

View File

@@ -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,

View File

@@ -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:

View File

@@ -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

View File

@@ -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():

View File

@@ -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)

View File

@@ -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-rc7 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",

View File

@@ -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-rc7}
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-rc7}
restart: unless-stopped
user: root
volumes:

View File

@@ -1 +1 @@
2025.8.0-rc1
2025.8.0-rc7

View File

@@ -26,7 +26,7 @@ Parameters:
Description: authentik Docker image
AuthentikVersion:
Type: String
Default: 2025.8.0-rc1
Default: 2025.8.0-rc7
Description: authentik Docker image tag
AuthentikServerCPU:
Type: Number

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/authentik",
"version": "2025.8.0-rc1",
"version": "2025.8.0-rc7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/authentik",
"version": "2025.8.0-rc1",
"version": "2025.8.0-rc7",
"dependencies": {
"@eslint/js": "^9.31.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/authentik",
"version": "2025.8.0-rc1",
"version": "2025.8.0-rc7",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.8.0-rc1"
version = "2025.8.0-rc7"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"

View File

@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2025.8.0-rc1
version: 2025.8.0-rc7
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
View File

@@ -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.0rc7"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },

12
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@goauthentik/web",
"version": "2025.8.0-rc1",
"version": "2025.8.0-rc7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@goauthentik/web",
"version": "2025.8.0-rc1",
"version": "2025.8.0-rc7",
"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",

View File

@@ -1,6 +1,6 @@
{
"name": "@goauthentik/web",
"version": "2025.8.0-rc1",
"version": "2025.8.0-rc7",
"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",

View File

@@ -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>
`;
}
}

View File

@@ -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>
`;
}
}

View File

@@ -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)}"

View File

@@ -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>`;
}

View File

@@ -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.
*

View File

@@ -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 {

View 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
View 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;
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -17,7 +17,7 @@ To try out the release candidate, replace your Docker image tag with the latest
![Screenshot of the admin interface showing events plotted on a histogram chart and on a map](../../sys-mgmt/events/event-map-chart.png)
- **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.

View File

@@ -26,8 +26,20 @@ 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.
![](./event-map-chart.png)
## Advanced queries for event logs:ak-enterprise {#tell-me-more}
You can construct advanced queries, based on [DjangoQL](https://github.com/ivelum/djangoql), 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.
- **Model/object**: `action`, `event_uuid`, `app`, `client_ip`, `user`, `brnad`, `context`, `created`
- **Operators**: `=`, `!=`, `~`, `!~`
- **Values**: `True`, `False`, `None`
- **Example queries**: `action = "login"`, `app startswith "N"`

View File

@@ -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,33 @@ 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 construct advanced queries, based on [DjangoQL](https://github.com/ivelum/djangoql), to find specific users in the list under **Directory > Users**. Use the auto-complete in the **Search** field or enter your own queries to return results with greater specificity.
<!--| Model/Object | Operators | Examples |
| ---- | ---- | ----------- |
| `username` | `=` | `username = "Joel Bonne"` |
| `path` | != | |
| `name` | ~| |
| `email` | !~ |
| `path` | `startswith` |
| `is_active` | `not startswith` |
| `type` | `endswith` |
| `attributes`| `endswith` |
| | `not endswith` |
| | `in` |
| | `not in` |-->
- **Model/object**: `username`, `path`, `name`, `email`, `path`, `is_active`, `type`, `attributes`
- **Operators**: `=`, `!=`, `~`, `!~`, `startswith`, `not startswith`, `endswidth`, `not endswith`, `in`, `not in`
- **Values**: `True`, `False`, `None`
- **Example queries**: `username = "Joel Bonne"`, `is_active = false`
## View user details
In the **Directory > Users** menu of the Admin interface, you can browse all the users in your authentik instance.
@@ -58,7 +84,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 +112,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 +126,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 +136,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.