Compare commits

..

12 Commits

Author SHA1 Message Date
d0f88f5214 website: bump react and @types/react in /website
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 18.3.1 to 19.1.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.0/packages/react)

Updates `@types/react` from 18.3.13 to 19.0.12
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-24 19:47:36 +00:00
2bef7695db translate: Updates for file locale/en/LC_MESSAGES/django.po in pt_BR [Manual Sync] (#14233)
Translate django.po in pt_BR [Manual Sync]

73% of minimum 60% translated source file: 'django.po'
on 'pt_BR'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-24 21:45:16 +02:00
df472dd842 Revert "website/docs: Prepare for monorepo. (#14119)" (#14239)
This reverts commit 5bdef1c4f6.
2025-04-24 21:44:13 +02:00
98d201d34c web: bump API Client version (#14236)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-24 19:01:26 +00:00
47e89602ab stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#14237)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-24 19:00:09 +00:00
ceb0851452 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_TW [Manual Sync] (#14235)
Translate django.po in zh_TW [Manual Sync]

78% of minimum 60% translated source file: 'django.po'
on 'zh_TW'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-24 18:56:13 +00:00
cac2593658 translate: Updates for file locale/en/LC_MESSAGES/django.po in tr [Manual Sync] (#14234)
Translate django.po in tr [Manual Sync]

90% of minimum 60% translated source file: 'django.po'
on 'tr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-24 18:53:54 +00:00
1c9705bfaa web: lock lit/ssr (#14214) 2025-04-24 18:38:32 +00:00
9e2566cec4 ci: fix npm packages publication not running (#14215) 2025-04-24 18:36:55 +00:00
5bdef1c4f6 website/docs: Prepare for monorepo. (#14119)
* docusaurus-theme: Fix header alignment, overscroll, vertical padding.

* docusaurus-theme: Lint.

* website/docs: Prepare for monorepo packages.

* website/docs: Clean up dependencies. Tidy table.

* website/docs: Fix issue where Prettier affects example content.

* website/docs: Temp fix for stale packages.
2025-04-24 18:22:56 +00:00
ae41ccd862 Revert package-lock.json changes from "web: add remember me feature to IdentificationStage (#10397)" (#14212)
Revert package-lock.json changes from "web: add remember me feature to IdentificationStage (#10397)"

This reverts parts of commit 5e6874cc1f.
2025-04-24 18:20:35 +00:00
337956672f Revert "web: Safari fixes merge branch (#14181)" (#14211) 2025-04-24 14:00:29 -04:00
38 changed files with 9303 additions and 7642 deletions

View File

@ -3,10 +3,10 @@ on:
push: push:
branches: [main] branches: [main]
paths: paths:
- packages/docusaurus-config - packages/docusaurus-config/**
- packages/eslint-config - packages/eslint-config/**
- packages/prettier-config - packages/prettier-config/**
- packages/tsconfig - packages/tsconfig/**
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish: publish:

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-11 00:10+0000\n" "POT-Creation-Date: 2025-04-23 09:00+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: Gil Poiares-Oliveira, 2025\n" "Last-Translator: Gil Poiares-Oliveira, 2025\n"
"Language-Team: Portuguese (Brazil) (https://app.transifex.com/authentik/teams/119923/pt_BR/)\n" "Language-Team: Portuguese (Brazil) (https://app.transifex.com/authentik/teams/119923/pt_BR/)\n"
@ -192,6 +192,7 @@ msgid "User's display name."
msgstr "Nome de exibição do usuário." msgstr "Nome de exibição do usuário."
#: authentik/core/models.py authentik/providers/oauth2/models.py #: authentik/core/models.py authentik/providers/oauth2/models.py
#: authentik/rbac/models.py
msgid "User" msgid "User"
msgstr "Usuário" msgstr "Usuário"
@ -376,6 +377,18 @@ msgstr "Mapeamento de propriedades"
msgid "Property Mappings" msgid "Property Mappings"
msgstr "Mapeamentos de propriedades" msgstr "Mapeamentos de propriedades"
#: authentik/core/models.py
msgid "session data"
msgstr ""
#: authentik/core/models.py
msgid "Session"
msgstr ""
#: authentik/core/models.py
msgid "Sessions"
msgstr ""
#: authentik/core/models.py #: authentik/core/models.py
msgid "Authenticated Session" msgid "Authenticated Session"
msgstr "Sessão Autenticada" msgstr "Sessão Autenticada"
@ -483,6 +496,38 @@ msgstr "Uso de licença"
msgid "License Usage Records" msgid "License Usage Records"
msgstr "Registros de uso de licença" msgstr "Registros de uso de licença"
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr ""
"Chave de campo para verificar, as chaves de campo definidas nos estágios de "
"prompt estão disponíveis."
#: authentik/enterprise/policies/unique_password/models.py
msgid "Number of passwords to check against."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr "Senha não definida no contexto"
#: authentik/enterprise/policies/unique_password/models.py
msgid "This password has been used previously. Please choose a different one."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policy"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policies"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "User Password History"
msgstr ""
#: authentik/enterprise/policy.py #: authentik/enterprise/policy.py
msgid "Enterprise required to access this feature." msgid "Enterprise required to access this feature."
msgstr "Entrerprise é necessário para acessar essa funcionalidade" msgstr "Entrerprise é necessário para acessar essa funcionalidade"
@ -1252,12 +1297,6 @@ msgstr ""
msgid "Clear Policy's cache metrics" msgid "Clear Policy's cache metrics"
msgstr "" msgstr ""
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr ""
"Chave de campo para verificar, as chaves de campo definidas nos estágios de "
"prompt estão disponíveis."
#: authentik/policies/password/models.py #: authentik/policies/password/models.py
msgid "How many times the password hash is allowed to be on haveibeenpwned" msgid "How many times the password hash is allowed to be on haveibeenpwned"
msgstr "Quantas vezes o hash da senha pode estar em haveibeenpwned" msgstr "Quantas vezes o hash da senha pode estar em haveibeenpwned"
@ -1268,10 +1307,6 @@ msgid ""
msgstr "" msgstr ""
"Se a pontuação zxcvbn for igual ou menor que esse valor, a política falhará." "Se a pontuação zxcvbn for igual ou menor que esse valor, a política falhará."
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr "Senha não definida no contexto"
#: authentik/policies/password/models.py #: authentik/policies/password/models.py
msgid "Invalid password." msgid "Invalid password."
msgstr "" msgstr ""
@ -1313,20 +1348,6 @@ msgstr "Pontuação de reputação"
msgid "Reputation Scores" msgid "Reputation Scores"
msgstr "Pontuações de reputação" msgstr "Pontuações de reputação"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr ""
#: authentik/policies/templates/policies/denied.html #: authentik/policies/templates/policies/denied.html
msgid "Permission denied" msgid "Permission denied"
msgstr "Permissão negada" msgstr "Permissão negada"
@ -2141,6 +2162,10 @@ msgstr ""
msgid "Roles" msgid "Roles"
msgstr "" msgstr ""
#: authentik/rbac/models.py
msgid "Initial Permissions"
msgstr ""
#: authentik/rbac/models.py #: authentik/rbac/models.py
msgid "System permission" msgid "System permission"
msgstr "Permissão do sistema" msgstr "Permissão do sistema"
@ -2387,6 +2412,22 @@ msgstr ""
msgid "LDAP Source Property Mappings" msgid "LDAP Source Property Mappings"
msgstr "" msgstr ""
#: authentik/sources/ldap/models.py
msgid "User LDAP Source Connection"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "User LDAP Source Connections"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Group LDAP Source Connection"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Group LDAP Source Connections"
msgstr ""
#: authentik/sources/ldap/signals.py #: authentik/sources/ldap/signals.py
msgid "Password does not match Active Directory Complexity." msgid "Password does not match Active Directory Complexity."
msgstr "A senha não corresponde à complexidade do Active Directory." msgstr "A senha não corresponde à complexidade do Active Directory."
@ -2395,6 +2436,14 @@ msgstr "A senha não corresponde à complexidade do Active Directory."
msgid "No token received." msgid "No token received."
msgstr "Nenhum token recebido." msgstr "Nenhum token recebido."
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr ""
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr ""
#: authentik/sources/oauth/models.py #: authentik/sources/oauth/models.py
msgid "Request Token URL" msgid "Request Token URL"
msgstr "URL do token de solicitação" msgstr "URL do token de solicitação"
@ -2435,6 +2484,12 @@ msgstr "URL usado pelo authentik para obter informações do usuário."
msgid "Additional Scopes" msgid "Additional Scopes"
msgstr "Escopos Adicionais" msgstr "Escopos Adicionais"
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request "
"flow"
msgstr ""
#: authentik/sources/oauth/models.py #: authentik/sources/oauth/models.py
msgid "OAuth Source" msgid "OAuth Source"
msgstr "Fonte OAuth" msgstr "Fonte OAuth"
@ -3318,6 +3373,12 @@ msgid ""
"info is entered." "info is entered."
msgstr "" msgstr ""
#: authentik/stages/identification/models.py
msgid ""
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
" to skip straight to entering their password."
msgstr ""
#: authentik/stages/identification/models.py #: authentik/stages/identification/models.py
msgid "Optional enrollment flow, which is linked at the bottom of the page." msgid "Optional enrollment flow, which is linked at the bottom of the page."
msgstr "Optional enrollment flow, which is linked at the bottom of the page." msgstr "Optional enrollment flow, which is linked at the bottom of the page."
@ -3678,6 +3739,14 @@ msgstr ""
"Os eventos serão excluídos após esta duração.(Formato: " "Os eventos serão excluídos após esta duração.(Formato: "
"semanas=3;dias=2;horas=3,segundos=2)." "semanas=3;dias=2;horas=3,segundos=2)."
#: authentik/tenants/models.py
msgid "Reputation cannot decrease lower than this value. Zero or negative."
msgstr ""
#: authentik/tenants/models.py
msgid "Reputation cannot increase higher than this value. Zero or positive."
msgstr ""
#: authentik/tenants/models.py #: authentik/tenants/models.py
msgid "The option configures the footer links on the flow executor pages." msgid "The option configures the footer links on the flow executor pages."
msgstr "" msgstr ""

View File

@ -13,7 +13,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n" "POT-Creation-Date: 2025-04-23 09:00+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: Jens L. <jens@goauthentik.io>, 2025\n" "Last-Translator: Jens L. <jens@goauthentik.io>, 2025\n"
"Language-Team: Turkish (https://app.transifex.com/authentik/teams/119923/tr/)\n" "Language-Team: Turkish (https://app.transifex.com/authentik/teams/119923/tr/)\n"
@ -187,6 +187,7 @@ msgid "User's display name."
msgstr "Kullanıcının görünen adı." msgstr "Kullanıcının görünen adı."
#: authentik/core/models.py authentik/providers/oauth2/models.py #: authentik/core/models.py authentik/providers/oauth2/models.py
#: authentik/rbac/models.py
msgid "User" msgid "User"
msgstr "Kullanıcı" msgstr "Kullanıcı"
@ -372,6 +373,18 @@ msgstr "Özellik Eşleme"
msgid "Property Mappings" msgid "Property Mappings"
msgstr "Özellik Eşlemeleri" msgstr "Özellik Eşlemeleri"
#: authentik/core/models.py
msgid "session data"
msgstr ""
#: authentik/core/models.py
msgid "Session"
msgstr "Oturum"
#: authentik/core/models.py
msgid "Sessions"
msgstr "Oturumlar"
#: authentik/core/models.py #: authentik/core/models.py
msgid "Authenticated Session" msgid "Authenticated Session"
msgstr "Kimliği Doğrulanmış Oturum" msgstr "Kimliği Doğrulanmış Oturum"
@ -479,6 +492,38 @@ msgstr "Lisans Kullanımı"
msgid "License Usage Records" msgid "License Usage Records"
msgstr "Lisans Kullanım Kayıtları" msgstr "Lisans Kullanım Kayıtları"
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr ""
"Alan tuşu kontrol etmek için, İstem aşamalarında tanımlanan alan tuşları "
"mevcuttur."
#: authentik/enterprise/policies/unique_password/models.py
msgid "Number of passwords to check against."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr "Parola bağlam içinde ayarlanmamış"
#: authentik/enterprise/policies/unique_password/models.py
msgid "This password has been used previously. Please choose a different one."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policy"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policies"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "User Password History"
msgstr ""
#: authentik/enterprise/policy.py #: authentik/enterprise/policy.py
msgid "Enterprise required to access this feature." msgid "Enterprise required to access this feature."
msgstr "Bu özelliğe erişmek için Kurumsal Paket gereklidir." msgstr "Bu özelliğe erişmek için Kurumsal Paket gereklidir."
@ -1253,12 +1298,6 @@ msgstr "İlke'nin önbellek ölçümlerini görüntüleme"
msgid "Clear Policy's cache metrics" msgid "Clear Policy's cache metrics"
msgstr "İlke'nin önbellek ölçümlerini temizleyin" msgstr "İlke'nin önbellek ölçümlerini temizleyin"
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr ""
"Alan tuşu kontrol etmek için, İstem aşamalarında tanımlanan alan tuşları "
"mevcuttur."
#: authentik/policies/password/models.py #: authentik/policies/password/models.py
msgid "How many times the password hash is allowed to be on haveibeenpwned" msgid "How many times the password hash is allowed to be on haveibeenpwned"
msgstr "" msgstr ""
@ -1271,10 +1310,6 @@ msgstr ""
"Eğer zxcvbn puanı bu değere eşit veya daha az ise, politika başarısız " "Eğer zxcvbn puanı bu değere eşit veya daha az ise, politika başarısız "
"olacaktır." "olacaktır."
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr "Parola bağlam içinde ayarlanmamış"
#: authentik/policies/password/models.py #: authentik/policies/password/models.py
msgid "Invalid password." msgid "Invalid password."
msgstr "" msgstr ""
@ -1316,20 +1351,6 @@ msgstr "İtibar Puanı"
msgid "Reputation Scores" msgid "Reputation Scores"
msgstr "İtibar Puanları" msgstr "İtibar Puanları"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr ""
#: authentik/policies/templates/policies/denied.html #: authentik/policies/templates/policies/denied.html
msgid "Permission denied" msgid "Permission denied"
msgstr "İzin reddedildi" msgstr "İzin reddedildi"
@ -2155,6 +2176,10 @@ msgstr "Rol"
msgid "Roles" msgid "Roles"
msgstr "Roller" msgstr "Roller"
#: authentik/rbac/models.py
msgid "Initial Permissions"
msgstr ""
#: authentik/rbac/models.py #: authentik/rbac/models.py
msgid "System permission" msgid "System permission"
msgstr "Sistem yetkisi" msgstr "Sistem yetkisi"
@ -2398,6 +2423,13 @@ msgstr ""
"Bir kullanıcı parolasını değiştirdiğinde, parolayı LDAP ile geri eşitleyin. " "Bir kullanıcı parolasını değiştirdiğinde, parolayı LDAP ile geri eşitleyin. "
"Bu yalnızca tek bir LDAP kaynağında etkinleştirilebilir." "Bu yalnızca tek bir LDAP kaynağında etkinleştirilebilir."
#: authentik/sources/ldap/models.py
msgid ""
"Lookup group membership based on a user attribute instead of a group "
"attribute. This allows nested group resolution on systems like FreeIPA and "
"Active Directory"
msgstr ""
#: authentik/sources/ldap/models.py #: authentik/sources/ldap/models.py
msgid "LDAP Source" msgid "LDAP Source"
msgstr "LDAP Kaynağı" msgstr "LDAP Kaynağı"
@ -2414,6 +2446,22 @@ msgstr "LDAP Kaynak Özellik Eşlemesi"
msgid "LDAP Source Property Mappings" msgid "LDAP Source Property Mappings"
msgstr "LDAP Kaynak Özellik Eşlemeleri" msgstr "LDAP Kaynak Özellik Eşlemeleri"
#: authentik/sources/ldap/models.py
msgid "User LDAP Source Connection"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "User LDAP Source Connections"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Group LDAP Source Connection"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Group LDAP Source Connections"
msgstr ""
#: authentik/sources/ldap/signals.py #: authentik/sources/ldap/signals.py
msgid "Password does not match Active Directory Complexity." msgid "Password does not match Active Directory Complexity."
msgstr "Parola Active Directory Karmaşıklığıyla eşleşmiyor." msgstr "Parola Active Directory Karmaşıklığıyla eşleşmiyor."
@ -2422,6 +2470,14 @@ msgstr "Parola Active Directory Karmaşıklığıyla eşleşmiyor."
msgid "No token received." msgid "No token received."
msgstr "Jeton alınmadı." msgstr "Jeton alınmadı."
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr ""
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr ""
#: authentik/sources/oauth/models.py #: authentik/sources/oauth/models.py
msgid "Request Token URL" msgid "Request Token URL"
msgstr "Jeton URL'si İste" msgstr "Jeton URL'si İste"
@ -2462,6 +2518,12 @@ msgstr "Kullanıcı bilgilerini almak için authentik tarafından kullanılan UR
msgid "Additional Scopes" msgid "Additional Scopes"
msgstr "Ek Kapsamlar" msgstr "Ek Kapsamlar"
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request "
"flow"
msgstr ""
#: authentik/sources/oauth/models.py #: authentik/sources/oauth/models.py
msgid "OAuth Source" msgid "OAuth Source"
msgstr "OAuth Kaynağı" msgstr "OAuth Kaynağı"
@ -3360,6 +3422,12 @@ msgstr ""
"Etkinleştirildiğinde, yanlış kullanıcı bilgisi girilse bile aşama başarılı " "Etkinleştirildiğinde, yanlış kullanıcı bilgisi girilse bile aşama başarılı "
"olur ve devam eder." "olur ve devam eder."
#: authentik/stages/identification/models.py
msgid ""
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
" to skip straight to entering their password."
msgstr ""
#: authentik/stages/identification/models.py #: authentik/stages/identification/models.py
msgid "Optional enrollment flow, which is linked at the bottom of the page." msgid "Optional enrollment flow, which is linked at the bottom of the page."
msgstr "Sayfanın alt kısmında bağlanan isteğe bağlı kayıt akışı." msgstr "Sayfanın alt kısmında bağlanan isteğe bağlı kayıt akışı."
@ -3734,6 +3802,14 @@ msgstr ""
"Olaylar bu süreden sonra silinecektir (Format: " "Olaylar bu süreden sonra silinecektir (Format: "
"weeks=3;days=2;hours=3,seconds=2)." "weeks=3;days=2;hours=3,seconds=2)."
#: authentik/tenants/models.py
msgid "Reputation cannot decrease lower than this value. Zero or negative."
msgstr ""
#: authentik/tenants/models.py
msgid "Reputation cannot increase higher than this value. Zero or positive."
msgstr ""
#: authentik/tenants/models.py #: authentik/tenants/models.py
msgid "The option configures the footer links on the flow executor pages." msgid "The option configures the footer links on the flow executor pages."
msgstr "" msgstr ""

View File

@ -14,7 +14,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-11 00:10+0000\n" "POT-Creation-Date: 2025-04-23 09:00+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: 刘松, 2025\n" "Last-Translator: 刘松, 2025\n"
"Language-Team: Chinese (Taiwan) (https://app.transifex.com/authentik/teams/119923/zh_TW/)\n" "Language-Team: Chinese (Taiwan) (https://app.transifex.com/authentik/teams/119923/zh_TW/)\n"
@ -178,6 +178,7 @@ msgid "User's display name."
msgstr "使用者的顯示名稱。" msgstr "使用者的顯示名稱。"
#: authentik/core/models.py authentik/providers/oauth2/models.py #: authentik/core/models.py authentik/providers/oauth2/models.py
#: authentik/rbac/models.py
msgid "User" msgid "User"
msgstr "使用者" msgstr "使用者"
@ -344,6 +345,18 @@ msgstr "屬性對應"
msgid "Property Mappings" msgid "Property Mappings"
msgstr "屬性對應" msgstr "屬性對應"
#: authentik/core/models.py
msgid "session data"
msgstr ""
#: authentik/core/models.py
msgid "Session"
msgstr "会话"
#: authentik/core/models.py
msgid "Sessions"
msgstr "会话"
#: authentik/core/models.py #: authentik/core/models.py
msgid "Authenticated Session" msgid "Authenticated Session"
msgstr "已認證會談" msgstr "已認證會談"
@ -447,6 +460,36 @@ msgstr "授權使用情況"
msgid "License Usage Records" msgid "License Usage Records"
msgstr "授權使用紀錄" msgstr "授權使用紀錄"
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr "要檢查的欄位鍵,在提示階段中有可用的已定義欄位鍵。"
#: authentik/enterprise/policies/unique_password/models.py
msgid "Number of passwords to check against."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr "未在上下文中設定密碼"
#: authentik/enterprise/policies/unique_password/models.py
msgid "This password has been used previously. Please choose a different one."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policy"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policies"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "User Password History"
msgstr ""
#: authentik/enterprise/policy.py #: authentik/enterprise/policy.py
msgid "Enterprise required to access this feature." msgid "Enterprise required to access this feature."
msgstr "企業版才能存取此功能。" msgstr "企業版才能存取此功能。"
@ -1176,10 +1219,6 @@ msgstr "檢視原則的快取指標"
msgid "Clear Policy's cache metrics" msgid "Clear Policy's cache metrics"
msgstr "清除原則的快取指標" msgstr "清除原則的快取指標"
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr "要檢查的欄位鍵,在提示階段中有可用的已定義欄位鍵。"
#: authentik/policies/password/models.py #: authentik/policies/password/models.py
msgid "How many times the password hash is allowed to be on haveibeenpwned" msgid "How many times the password hash is allowed to be on haveibeenpwned"
msgstr "密碼雜湊在 haveibeenpwned 上允許出現的次數" msgstr "密碼雜湊在 haveibeenpwned 上允許出現的次數"
@ -1189,10 +1228,6 @@ msgid ""
"If the zxcvbn score is equal or less than this value, the policy will fail." "If the zxcvbn score is equal or less than this value, the policy will fail."
msgstr "如果 zxcvbn 分數等於或小於此值,則該政策將失敗。" msgstr "如果 zxcvbn 分數等於或小於此值,則該政策將失敗。"
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr "未在上下文中設定密碼"
#: authentik/policies/password/models.py #: authentik/policies/password/models.py
msgid "Invalid password." msgid "Invalid password."
msgstr "" msgstr ""
@ -1234,20 +1269,6 @@ msgstr "信譽分數"
msgid "Reputation Scores" msgid "Reputation Scores"
msgstr "信譽分數" msgstr "信譽分數"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr ""
#: authentik/policies/templates/policies/denied.html #: authentik/policies/templates/policies/denied.html
msgid "Permission denied" msgid "Permission denied"
msgstr "權限不足。" msgstr "權限不足。"
@ -1999,6 +2020,10 @@ msgstr "角色"
msgid "Roles" msgid "Roles"
msgstr "角色" msgstr "角色"
#: authentik/rbac/models.py
msgid "Initial Permissions"
msgstr ""
#: authentik/rbac/models.py #: authentik/rbac/models.py
msgid "System permission" msgid "System permission"
msgstr "系統權限" msgstr "系統權限"
@ -2240,6 +2265,22 @@ msgstr ""
msgid "LDAP Source Property Mappings" msgid "LDAP Source Property Mappings"
msgstr "" msgstr ""
#: authentik/sources/ldap/models.py
msgid "User LDAP Source Connection"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "User LDAP Source Connections"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Group LDAP Source Connection"
msgstr ""
#: authentik/sources/ldap/models.py
msgid "Group LDAP Source Connections"
msgstr ""
#: authentik/sources/ldap/signals.py #: authentik/sources/ldap/signals.py
msgid "Password does not match Active Directory Complexity." msgid "Password does not match Active Directory Complexity."
msgstr "密碼不符合 Active Directory 的複雜性要求。" msgstr "密碼不符合 Active Directory 的複雜性要求。"
@ -2248,6 +2289,14 @@ msgstr "密碼不符合 Active Directory 的複雜性要求。"
msgid "No token received." msgid "No token received."
msgstr "未收到權杖。" msgstr "未收到權杖。"
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr ""
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr ""
#: authentik/sources/oauth/models.py #: authentik/sources/oauth/models.py
msgid "Request Token URL" msgid "Request Token URL"
msgstr "請求權杖的網址" msgstr "請求權杖的網址"
@ -2286,6 +2335,12 @@ msgstr "authentik 用來擷取使用者資訊的網址。"
msgid "Additional Scopes" msgid "Additional Scopes"
msgstr "附加範圍" msgstr "附加範圍"
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request "
"flow"
msgstr ""
#: authentik/sources/oauth/models.py #: authentik/sources/oauth/models.py
msgid "OAuth Source" msgid "OAuth Source"
msgstr "OAuth 來源" msgstr "OAuth 來源"
@ -3137,6 +3192,12 @@ msgid ""
"info is entered." "info is entered."
msgstr "" msgstr ""
#: authentik/stages/identification/models.py
msgid ""
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
" to skip straight to entering their password."
msgstr ""
#: authentik/stages/identification/models.py #: authentik/stages/identification/models.py
msgid "Optional enrollment flow, which is linked at the bottom of the page." msgid "Optional enrollment flow, which is linked at the bottom of the page."
msgstr "可選的註冊流程,連結在頁面的底部。" msgstr "可選的註冊流程,連結在頁面的底部。"
@ -3481,6 +3542,14 @@ msgid ""
"weeks=3;days=2;hours=3,seconds=2)." "weeks=3;days=2;hours=3,seconds=2)."
msgstr "事件將在此期間後刪除。格式weeks=3;days=2;hours=3,seconds=2" msgstr "事件將在此期間後刪除。格式weeks=3;days=2;hours=3,seconds=2"
#: authentik/tenants/models.py
msgid "Reputation cannot decrease lower than this value. Zero or negative."
msgstr ""
#: authentik/tenants/models.py
msgid "Reputation cannot increase higher than this value. Zero or positive."
msgstr ""
#: authentik/tenants/models.py #: authentik/tenants/models.py
msgid "The option configures the footer links on the flow executor pages." msgid "The option configures the footer links on the flow executor pages."
msgstr "" msgstr ""

12184
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@
"@floating-ui/dom": "^1.6.11", "@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.4-1745325566", "@goauthentik/api": "^2025.2.4-1745519715",
"@lit-labs/ssr": "3.2.2",
"@lit/context": "^1.1.2", "@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2", "@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4", "@lit/reactive-element": "^2.0.4",
@ -53,7 +54,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.0.0", "remark-mdx-frontmatter": "^5.0.0",
"style-mod": "^4.1.2", "style-mod": "^4.1.2",
"trusted-types": "^2.0.0",
"ts-pattern": "^5.4.0", "ts-pattern": "^5.4.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"webcomponent-qr-code": "^1.2.0", "webcomponent-qr-code": "^1.2.0",

View File

@ -4,17 +4,13 @@ import { ROUTES } from "@goauthentik/admin/Routes";
import { import {
EVENT_API_DRAWER_TOGGLE, EVENT_API_DRAWER_TOGGLE,
EVENT_NOTIFICATION_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE,
EVENT_SIDEBAR_TOGGLE,
} from "@goauthentik/common/constants"; } from "@goauthentik/common/constants";
import { configureSentry } from "@goauthentik/common/sentry"; import { configureSentry } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws"; import { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/banner/EnterpriseStatusBanner"; import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/VersionBanner";
import "@goauthentik/elements/banner/VersionBanner"; import "@goauthentik/elements/banner/VersionBanner";
import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer";
@ -25,32 +21,25 @@ import "@goauthentik/elements/router/RouterOutlet";
import "@goauthentik/elements/sidebar/Sidebar"; import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem"; import "@goauthentik/elements/sidebar/SidebarItem";
import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api"; import { SessionUser, UiThemeEnum } from "@goauthentik/api";
import { import "./AdminSidebar";
AdminSidebarEnterpriseEntries,
AdminSidebarEntries,
renderSidebarItems,
} from "./AdminSidebar.js";
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client"); await import("@goauthentik/esbuild-plugin-live-reload/client");
} }
@customElement("ak-interface-admin") @customElement("ak-interface-admin")
export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) { export class AdminInterface extends AuthenticatedInterface {
//#region Properties
@property({ type: Boolean }) @property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@ -65,29 +54,12 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
@query("ak-about-modal") @query("ak-about-modal")
aboutModal?: AboutModal; aboutModal?: AboutModal;
@property({ type: Boolean, reflect: true })
public sidebarOpen: boolean;
#toggleSidebar = () => {
this.sidebarOpen = !this.sidebarOpen;
};
#sidebarMatcher: MediaQueryList;
#sidebarListener = (event: MediaQueryListEvent) => {
this.sidebarOpen = event.matches;
};
//#endregion
//#region Styles
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
PFBase, PFBase,
PFPage, PFPage,
PFButton, PFButton,
PFDrawer, PFDrawer,
PFNav,
css` css`
.pf-c-page__main, .pf-c-page__main,
.pf-c-drawer__content, .pf-c-drawer__content,
@ -95,30 +67,23 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
z-index: auto !important; z-index: auto !important;
background-color: transparent; background-color: transparent;
} }
.display-none { .display-none {
display: none; display: none;
} }
.pf-c-page { .pf-c-page {
background-color: var(--pf-c-page--BackgroundColor) !important; background-color: var(--pf-c-page--BackgroundColor) !important;
} }
/* Global page background colour */
:host([theme="dark"]) { :host([theme="dark"]) .pf-c-page {
/* Global page background colour */ --pf-c-page--BackgroundColor: var(--ak-dark-background);
.pf-c-page {
--pf-c-page--BackgroundColor: var(--ak-dark-background);
}
} }
ak-enterprise-status,
ak-page-navbar { ak-version-banner {
grid-area: header; grid-area: header;
} }
ak-admin-sidebar {
.ak-sidebar {
grid-area: nav; grid-area: nav;
} }
.pf-c-drawer__panel { .pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl); z-index: var(--pf-global--ZIndex--xl);
} }
@ -126,23 +91,10 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
]; ];
} }
//#endregion
//#region Lifecycle
constructor() { constructor() {
super(); super();
this.ws = new WebsocketClient(); this.ws = new WebsocketClient();
this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
this.sidebarOpen = this.#sidebarMatcher.matches;
}
public connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen; this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({ updateURLParams({
@ -156,14 +108,6 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
apiDrawerOpen: this.apiDrawerOpen, apiDrawerOpen: this.apiDrawerOpen,
}); });
}); });
this.#sidebarMatcher.addEventListener("change", this.#sidebarListener);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener);
} }
async firstUpdated(): Promise<void> { async firstUpdated(): Promise<void> {
@ -174,7 +118,6 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
this.user.user.isSuperuser || this.user.user.isSuperuser ||
// TODO: somehow add `access_admin_interface` to the API schema // TODO: somehow add `access_admin_interface` to the API schema
this.user.user.systemPermissions.includes("access_admin_interface"); this.user.user.systemPermissions.includes("access_admin_interface");
if (!canAccessAdmin && this.user.user.pk > 0) { if (!canAccessAdmin && this.user.user.pk > 0) {
window.location.assign("/if/user/"); window.location.assign("/if/user/");
} }
@ -182,14 +125,10 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
render(): TemplateResult { render(): TemplateResult {
const sidebarClasses = { const sidebarClasses = {
"pf-c-page__sidebar": true,
"pf-m-light": this.activeTheme === UiThemeEnum.Light, "pf-m-light": this.activeTheme === UiThemeEnum.Light,
"pf-m-expanded": this.sidebarOpen,
"pf-m-collapsed": !this.sidebarOpen,
}; };
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen; const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
const drawerClasses = { const drawerClasses = {
"pf-m-expanded": drawerOpen, "pf-m-expanded": drawerOpen,
"pf-m-collapsed": !drawerOpen, "pf-m-collapsed": !drawerOpen,
@ -197,18 +136,11 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
return html` <ak-locale-context> return html` <ak-locale-context>
<div class="pf-c-page"> <div class="pf-c-page">
<ak-page-navbar> <ak-enterprise-status interface="admin"></ak-enterprise-status>
<ak-version-banner></ak-version-banner> <ak-version-banner></ak-version-banner>
<ak-enterprise-status interface="admin"></ak-enterprise-status> <ak-admin-sidebar
</ak-page-navbar> class="pf-c-page__sidebar ${classMap(sidebarClasses)}"
></ak-admin-sidebar>
<ak-sidebar class="${classMap(sidebarClasses)}">
${renderSidebarItems(AdminSidebarEntries)}
${this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed
? renderSidebarItems(AdminSidebarEnterpriseEntries)
: nothing}
</ak-sidebar>
<div class="pf-c-page__drawer"> <div class="pf-c-page__drawer">
<div class="pf-c-drawer ${classMap(drawerClasses)}"> <div class="pf-c-drawer ${classMap(drawerClasses)}">
<div class="pf-c-drawer__main"> <div class="pf-c-drawer__main">

View File

@ -1,97 +1,186 @@
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base";
import {
CapabilitiesEnum,
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { spread } from "@open-wc/lit-helpers"; import { spread } from "@open-wc/lit-helpers";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js"; import { customElement, property, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
// The second attribute type is of string[] to help with the 'activeWhen' control, which was import { UiThemeEnum } from "@goauthentik/api";
// commonplace and singular enough to merit its own handler. import type { SessionUser, UserSelf } from "@goauthentik/api";
type SidebarEntry = [
path: string | null,
label: string,
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
children?: SidebarEntry[],
];
/** @customElement("ak-admin-sidebar")
* Recursively renders a sidebar entry. export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement)) {
*/ @property({ type: Boolean, reflect: true })
export function renderSidebarItem([ open = true;
path,
label,
attributes,
children,
]: SidebarEntry): TemplateResult {
const properties = Array.isArray(attributes)
? { ".activeWhen": attributes }
: (attributes ?? {});
if (path) { @state()
properties.path = path; impersonation: UserSelf["username"] | null = null;
constructor() {
super();
me().then((user: SessionUser) => {
this.impersonation = user.original ? user.user.username : null;
});
this.toggleOpen = this.toggleOpen.bind(this);
this.checkWidth = this.checkWidth.bind(this);
} }
return html`<ak-sidebar-item ${spread(properties)}> // This has to be a bound method so the event listener can be removed on disconnection as
${label ? html`<span slot="label">${label}</span>` : nothing} // needed.
${children ? renderSidebarItems(children) : nothing} toggleOpen() {
</ak-sidebar-item>`; this.open = !this.open;
}
checkWidth() {
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
// REMs. If that changes, this code will have to be adjusted as well.
const minWidth =
parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) *
parseFloat(getRootStyle("font-size"));
this.open = window.innerWidth >= minWidth;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
window.addEventListener("resize", this.checkWidth);
// After connecting to the DOM, we can now perform this check to see if the sidebar should
// be open by default.
this.checkWidth();
}
// The symmetry (☟, ☝) here is critical in that you want to start adding these handlers after
// connection, and removing them before disconnection.
disconnectedCallback() {
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
window.removeEventListener("resize", this.checkWidth);
super.disconnectedCallback();
}
render() {
return html`
<ak-sidebar
class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this
.activeTheme === UiThemeEnum.Light
? "pf-m-light"
: ""}"
>
${this.renderSidebarItems()}
</ak-sidebar>
`;
}
updated() {
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
// scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
// a browser reflow, which may trigger some other styling the application is monitoring,
// triggering a re-render which triggers a browser reflow, ad infinitum. But we've been
// living with that since jQuery, and it's both well-known and fortunately rare.
// eslint-disable-next-line wc/no-self-class
this.classList.remove("pf-m-expanded", "pf-m-collapsed");
// eslint-disable-next-line wc/no-self-class
this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
}
renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.
type SidebarEntry = [
path: string | null,
label: string,
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
children?: SidebarEntry[],
];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [
[null, msg("Dashboards"), { "?expanded": true }, [
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]],
[null, msg("Applications"), null, [
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]],
[null, msg("Customization"), null, [
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]],
[null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_REGEX})$`]],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]],
[null, msg("System"), null, [
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")]]],
];
// Typescript requires the type here to correctly type the recursive path
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
const properties = Array.isArray(attributes)
? { ".activeWhen": attributes }
: (attributes ?? {});
if (path) {
properties.path = path;
}
return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)}
</ak-sidebar-item>`;
};
// prettier-ignore
return html`
${map(sidebarContent, renderOneSidebarItem)}
${this.renderEnterpriseMenu()}
`;
}
renderEnterpriseMenu() {
return this.can(CapabilitiesEnum.IsEnterprise)
? html`
<ak-sidebar-item>
<span slot="label">${msg("Enterprise")}</span>
<ak-sidebar-item path="/enterprise/licenses">
<span slot="label">${msg("Licenses")}</span>
</ak-sidebar-item>
</ak-sidebar-item>
`
: nothing;
}
} }
/** declare global {
* Recursively renders a collection of sidebar entries. interface HTMLElementTagNameMap {
*/ "ak-admin-sidebar": AkAdminSidebar;
export function renderSidebarItems(entries: readonly SidebarEntry[]) { }
return repeat(entries, ([path, label]) => path || label, renderSidebarItem);
} }
// prettier-ignore
export const AdminSidebarEntries: readonly SidebarEntry[] = [
[null, msg("Dashboards"), { "?expanded": true }, [
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]
],
[null, msg("Applications"), null, [
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/outpost/outposts", msg("Outposts")]]
],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]
],
[null, msg("Customization"), null, [
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")]]
],
[null, msg("Flows and Stages"), null, [
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")]]
],
[null, msg("Directory"), null, [
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_REGEX})$`]],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")]]
],
[null, msg("System"), null, [
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")]]
],
];
// prettier-ignore
export const AdminSidebarEnterpriseEntries: readonly SidebarEntry[] = [
[null, msg("Enterprise"), null, [
["/enterprise/licenses", msg("Licenses"), null]
],
]]

View File

@ -94,13 +94,10 @@ export class AdminOverviewPage extends AdminOverviewBase {
} }
render(): TemplateResult { render(): TemplateResult {
const username = this.user?.user.name || this.user?.user.username; const name = this.user?.user.name ?? this.user?.user.username;
return html` <ak-page-header return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}>
header=${msg(str`Welcome, ${username || ""}.`)} <span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span>
description=${msg("General system status")}
?hasIcon=${false}
>
</ak-page-header> </ak-page-header>
<section class="pf-c-page__main-section"> <section class="pf-c-page__main-section">
<div class="pf-l-grid pf-m-gutter"> <div class="pf-l-grid pf-m-gutter">

View File

@ -83,10 +83,13 @@ export class AdminSettingsPage extends AKElement {
} }
render() { render() {
if (!this.settings) return nothing; if (!this.settings) {
return nothing;
}
return html` return html`
<ak-page-header icon="fa fa-cog" header="${msg("System settings")}"> </ak-page-header> <ak-page-header icon="fa fa-cog" header="" description="">
<span slot="header"> ${msg("System settings")} </span>
</ak-page-header>
<section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter"> <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card"> <div class="pf-c-card">
<div class="pf-c-card__body"> <div class="pf-c-card__body">

View File

@ -1,110 +1,26 @@
import type { Config as DOMPurifyConfig } from "dompurify"; import type { Config as DOMPurifyConfig } from "dompurify";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { trustedTypes } from "trusted-types";
import { render } from "lit"; import { render } from "@lit-labs/ssr";
import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
import { TemplateResult, html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { until } from "lit/directives/until.js";
/**
* Trusted types policy that escapes HTML content in place.
*
* @see {@linkcode SanitizedTrustPolicy} to strip HTML content.
*
* @returns {TrustedHTML} All HTML content, escaped.
*/
export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
});
},
});
/**
* Trusted types policy, stripping all HTML content.
*
* @returns {TrustedHTML} Text content only, all HTML tags stripped.
*/
export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitize", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
ALLOWED_TAGS: ["#text"],
});
},
});
/**
* Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by
* a trusted source, such as the brand API.
*/
export const BrandedHTMLPolicy = trustedTypes.createPolicy("authentik-restrict", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
FORBID_TAGS: [
"script",
"style",
"iframe",
"link",
"object",
"embed",
"applet",
"meta",
"base",
"form",
"input",
"textarea",
"select",
"button",
],
FORBID_ATTR: [
"onerror",
"onclick",
"onload",
"onmouseover",
"onmouseout",
"onmouseup",
"onmousedown",
"onfocus",
"onblur",
"onsubmit",
],
});
},
});
export type AuthentikTrustPolicy =
| typeof EscapeTrustPolicy
| typeof SanitizedTrustPolicy
| typeof BrandedHTMLPolicy;
/**
* Sanitize an untrusted HTML string using a trusted types policy.
*/
export function sanitizeHTML(trustPolicy: AuthentikTrustPolicy, untrustedHTML: string) {
return unsafeHTML(trustPolicy.createHTML(untrustedHTML).toString());
}
/**
* DOMPurify configuration for strict sanitization.
*
* This configuration only allows text nodes and disallows all HTML tags.
*/
export const DOM_PURIFY_STRICT = { export const DOM_PURIFY_STRICT = {
ALLOWED_TAGS: ["#text"], ALLOWED_TAGS: ["#text"],
} as const satisfies DOMPurifyConfig; } as const satisfies DOMPurifyConfig;
/** export async function renderStatic(input: TemplateResult): Promise<string> {
* Render untrusted HTML to a string without escaping it. return await collectResult(render(input));
* }
* @returns {string} The rendered HTML string.
*/ export function purify(input: TemplateResult): TemplateResult {
export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string { return html`${until(
const container = document.createElement("html"); (async () => {
render(untrustedHTML, container); const rendered = await renderStatic(input);
const purified = DOMPurify.sanitize(rendered);
const result = container.innerHTML; return html`${unsafeHTML(purified)}`;
})(),
return result; )}`;
} }

View File

@ -17,13 +17,6 @@
/* Minimum width after which the sidebar becomes automatic */ /* Minimum width after which the sidebar becomes automatic */
--ak-sidebar--minimum-auto-width: 80rem; --ak-sidebar--minimum-auto-width: 80rem;
/**
* The height of the navbar and branded sidebar.
* @todo This shouldn't be necessary. The sidebar can instead use a grid layout
* ensuring they share the same height.
*/
--ak-navbar--height: 7rem;
} }
@supports selector(::-webkit-scrollbar) { @supports selector(::-webkit-scrollbar) {

View File

@ -1,264 +0,0 @@
/**
* @file Stylesheet utilities.
*/
import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit";
/**
* Elements containing adoptable stylesheets.
*/
export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">;
/**
* Type-predicate to determine if a given object has adoptable stylesheets.
*/
export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent {
// Sanity check - Does the input have the right shape?
if (!input || typeof input !== "object") return false;
if (!("adoptedStyleSheets" in input) || !input.adoptedStyleSheets) return false;
if (typeof input.adoptedStyleSheets !== "object") return false;
// We avoid `Array.isArray` because the adopted stylesheets property
// is defined as a proxied array.
// All we care about is that it's shaped like an array.
if (!("length" in input.adoptedStyleSheets)) return false;
if (typeof input.adoptedStyleSheets.length !== "number") return false;
// Finally is the array mutable?
return "push" in input.adoptedStyleSheets;
}
/**
* Assert that the given input can adopt stylesheets.
*/
export function assertAdoptableStyleSheetParent<T>(
input: T,
): asserts input is T & StyleSheetParent {
if (isAdoptableStyleSheetParent(input)) return;
console.debug("Given input missing `adoptedStyleSheets`", input);
throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input");
}
export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>(
renderRoot: T,
) {
const styleRoot = "ShadyDOM" in window ? document : renderRoot;
assertAdoptableStyleSheetParent(styleRoot);
return styleRoot;
}
export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
/**
* Given a source of CSS, create a `CSSStyleSheet`.
*
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
*
* @remarks
*
* Storybook's `build` does not currently have a coherent way of importing
* CSS-as-text into CSSStyleSheet.
*
* It works well when Storybook is running in `dev`, but in `build` it fails.
* Storied components will have to map their textual CSS imports.
*/
export function createStyleSheet(input: string): CSSResult {
const inputTemplate = [input] as unknown as TemplateStringsArray;
const result = css(inputTemplate, []);
return result;
}
/**
* Given a source of CSS, create a `CSSStyleSheet`.
*
* @see {@linkcode createStyleSheet}
*/
export function normalizeCSSSource(css: string): CSSStyleSheet;
export function normalizeCSSSource(styleSheet: CSSStyleSheet): CSSStyleSheet;
export function normalizeCSSSource(cssResult: CSSResult): CSSResult;
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative;
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative {
if (typeof input === "string") return createStyleSheet(input);
return input;
}
/**
* Create a `CSSStyleSheet` from the given input.
*/
export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet {
const result = normalizeCSSSource(input);
if (result instanceof CSSStyleSheet) return result;
if (!result.styleSheet) {
console.debug(
"authentik/common/stylesheets: CSSResult missing styleSheet, returning empty",
{ result, input },
);
throw new TypeError("Expected a CSSStyleSheet");
}
return result.styleSheet;
}
/**
* A symbol to indicate that a stylesheet has been adopted by a style parent.
*
* @remarks
* Safari considers stylesheet removed from the `adoptedStyleSheets` array
* ready for garbage collection. Reuse of the stylesheet will result in tab-crash.
*
* Always discard the stylesheet after use.
*/
const StyleSheetAdoptedParent = Symbol("stylesheet-adopted");
/**
* A CSS style sheet that has been adopted by a style parent.
*/
export interface AdoptedStyleSheet extends CSSStyleSheet {
[StyleSheetAdoptedParent]: WeakRef<StyleSheetParent>;
}
/**
* Type-predicate to determine if a given stylesheet has been adopted.
*/
export function isAdoptedStyleSheet(styleSheet: CSSStyleSheet): styleSheet is AdoptedStyleSheet {
if (!(StyleSheetAdoptedParent in styleSheet)) return false;
return styleSheet[StyleSheetAdoptedParent] instanceof WeakRef;
}
/**
* Append stylesheet(s) to the given roots.
*
* @see {@linkcode removeStyleSheet} to remove a stylesheet from a given roots.
*/
export function appendStyleSheet(
styleParent: StyleSheetParent,
...insertions: CSSStyleSheet[]
): void {
insertions = Array.isArray(insertions) ? insertions : [insertions];
for (const styleSheetInsertion of insertions) {
if (isAdoptedStyleSheet(styleSheetInsertion)) {
console.warn("Attempted to append adopted stylesheet", {
styleSheetInsertion,
currentParent: styleSheetInsertion[StyleSheetAdoptedParent]?.deref(),
rules: serializeStyleSheet(styleSheetInsertion),
});
throw new TypeError("Attempted to append a previously adopted stylesheet");
}
if (styleParent.adoptedStyleSheets.includes(styleSheetInsertion)) return;
styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, styleSheetInsertion];
Object.assign(styleSheetInsertion, {
[StyleSheetAdoptedParent]: new WeakRef(styleParent),
});
}
}
/**
* Remove a stylesheet from the given roots, matching by referential equality.
*
* @see {@linkcode appendStyleSheet} to append a stylesheet to a given roots.
*/
export function removeStyleSheet(
styleParent: StyleSheetParent,
...removals: CSSStyleSheet[]
): void {
const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter(
(styleSheet) => !removals.includes(styleSheet),
);
if (nextAdoptedStyleSheets.length === styleParent.adoptedStyleSheets.length) return;
styleParent.adoptedStyleSheets = nextAdoptedStyleSheets;
}
/**
* Serialize a stylesheet to a string.
*
* This is useful for debugging or inspecting the contents of a stylesheet.
*/
export function serializeStyleSheet(stylesheet: CSSStyleSheet): string {
return Array.from(stylesheet.cssRules || [], (rule) => rule.cssText || "").join("\n");
}
/**
* Inspect the adopted stylesheets of a given style parent, serializing them to strings.
*/
export function inspectStyleSheets(styleParent: StyleSheetParent): string[] {
return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
}
interface InspectedStyleSheetEntry {
tagName: string;
element: ReactiveElement;
styles: string[];
children?: InspectedStyleSheetEntry[];
}
/**
* Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings.
*/
export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry {
const styleParent = resolveStyleSheetParent(element.renderRoot);
const styles = inspectStyleSheets(styleParent);
const tagName = element.tagName.toLowerCase();
const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
if (node instanceof ReactiveElement) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
},
});
const children: InspectedStyleSheetEntry[] = [];
let currentNode: Node | null = treewalker.nextNode();
while (currentNode) {
const childElement = currentNode as ReactiveElement;
if (!isAdoptableStyleSheetParent(childElement.renderRoot)) {
currentNode = treewalker.nextNode();
continue;
}
const childStyles = inspectStyleSheets(childElement.renderRoot);
children.push({
tagName: childElement.tagName.toLowerCase(),
element: childElement,
styles: childStyles,
});
currentNode = treewalker.nextNode();
}
return {
tagName,
element,
styles,
children,
};
}
if (process.env.NODE_ENV === "development") {
Object.assign(window, {
inspectStyleSheetTree,
serializeStyleSheet,
inspectStyleSheets,
});
}

View File

@ -1,200 +0,0 @@
/**
* @file Theme utilities.
*/
import { UIConfig } from "@goauthentik/common/ui/config";
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
//#region Scheme Types
/**
* Valid CSS color scheme values.
*
* @link {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme | MDN}
*
* @category CSS
*/
export type CSSColorSchemeValue = "dark" | "light" | "auto";
/**
* A CSS color scheme value that can be preferred by the user, i.e. not `"auto"`.
*
* @category CSS
*/
export type ResolvedCSSColorSchemeValue = Exclude<CSSColorSchemeValue, "auto">;
//#endregion
//#region UI Theme Types
/**
* A UI color scheme value that can be preferred by the user.
*
* i.e. not an lack of preference or unknown value.
*
* @category CSS
*/
export type ResolvedUITheme = typeof UiThemeEnum.Light | typeof UiThemeEnum.Dark;
/**
* A mapping of theme values to their respective inversion.
*
* @category CSS
*/
export const UIThemeInversion = {
dark: "light",
light: "dark",
} as const satisfies Record<ResolvedUITheme, ResolvedUITheme>;
/**
* Either a valid CSS color scheme value, or a theme preference.
*/
export type UIThemeHint = CSSColorSchemeValue | UiThemeEnum;
//#endregion
//#region Scheme Functions
/**
* Creates an event target for the given color scheme.
*
* @param colorScheme The color scheme to target.
* @returns A {@linkcode MediaQueryList} that can be used to listen for changes to the color scheme.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList | MDN}
*
* @category CSS
*/
export function createColorSchemeTarget(colorScheme: ResolvedCSSColorSchemeValue): MediaQueryList {
return window.matchMedia(`(prefers-color-scheme: ${colorScheme})`);
}
/**
* Formats the given input into a valid CSS color scheme value.
*
* If the input is not provided, it defaults to "auto".
*
* @category CSS
*/
export function formatColorScheme(theme: ResolvedUITheme): ResolvedCSSColorSchemeValue;
export function formatColorScheme(
colorScheme: ResolvedCSSColorSchemeValue,
): ResolvedCSSColorSchemeValue;
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue;
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue {
if (!hint) return "auto";
switch (hint) {
case "dark":
case UiThemeEnum.Dark:
return "dark";
case "light":
case UiThemeEnum.Light:
return "light";
case "auto":
case UiThemeEnum.Automatic:
return "auto";
default:
console.warn(`Unknown color scheme hint: ${hint}. Defaulting to "auto".`);
return "auto";
}
}
//#endregion
//#region Theme Functions
/**
* Resolve the current UI theme based on the user's preference or the provided color scheme.
*
* @param hint The color scheme hint to use.
*
* @category CSS
*/
export function resolveUITheme(
hint?: UIThemeHint,
defaultUITheme: ResolvedUITheme = UiThemeEnum.Light,
): ResolvedUITheme {
const colorScheme = formatColorScheme(hint);
if (colorScheme !== "auto") return colorScheme;
// Given that we don't know the user's preference,
// we can determine the theme based on whether the default theme is
// currently being overridden.
const colorSchemeInversion = formatColorScheme(UIThemeInversion[defaultUITheme]);
const mediaQueryList = createColorSchemeTarget(colorSchemeInversion);
return mediaQueryList.matches ? colorSchemeInversion : defaultUITheme;
}
/**
* Effect listener invoked when the color scheme changes.
*/
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
/**
* Create an effect that runs
*
* @returns A cleanup function that removes the effect.
*/
export function createUIThemeEffect(
effect: UIThemeListener,
listenerOptions?: AddEventListenerOptions,
): () => void {
const colorSchemeTarget = resolveUITheme();
const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
let previousUITheme: ResolvedUITheme | undefined;
// First, wrap the effect to ensure we can abort it.
const changeListener = (event: MediaQueryListEvent) => {
if (listenerOptions?.signal?.aborted) return;
const currentUITheme = event.matches ? colorSchemeTarget : invertedColorSchemeTarget;
if (previousUITheme === currentUITheme) return;
previousUITheme = currentUITheme;
effect(currentUITheme);
};
const mediaQueryList = createColorSchemeTarget(colorSchemeTarget);
// Trigger the effect immediately.
effect(colorSchemeTarget);
// Listen for changes to the color scheme...
mediaQueryList.addEventListener("change", changeListener, listenerOptions);
// Finally, allow the caller to remove the effect.
const cleanup = () => {
mediaQueryList.removeEventListener("change", changeListener);
};
return cleanup;
}
//#endregion
//#region Theme Element
/**
* An element that can be themed.
*/
export interface ThemedElement extends HTMLElement {
brand?: CurrentBrand;
uiConfig?: UIConfig;
config?: Config;
activeTheme: ResolvedUITheme;
}
export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null {
const element = document.body.querySelector<T>("[data-ak-interface-root]");
return element;
}
//#endregion

View File

@ -95,7 +95,7 @@ export class NavigationButtons extends AKElement {
); );
}; };
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl"> return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg">
<button class="pf-c-button pf-m-plain" type="button" @click=${onClick}> <button class="pf-c-button pf-m-plain" type="button" @click=${onClick}>
<pf-tooltip position="top" content=${msg("Open API drawer")}> <pf-tooltip position="top" content=${msg("Open API drawer")}>
<i class="fas fa-code" aria-hidden="true"></i> <i class="fas fa-code" aria-hidden="true"></i>
@ -116,7 +116,7 @@ export class NavigationButtons extends AKElement {
); );
}; };
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl"> return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg">
<button <button
class="pf-c-button pf-m-plain" class="pf-c-button pf-m-plain"
type="button" type="button"
@ -156,7 +156,9 @@ export class NavigationButtons extends AKElement {
} }
renderImpersonation() { renderImpersonation() {
if (!this.me?.original) return nothing; if (!this.me?.original) {
return nothing;
}
const onClick = async () => { const onClick = async () => {
await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve(); await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
@ -173,14 +175,6 @@ export class NavigationButtons extends AKElement {
</div>`; </div>`;
} }
renderAvatar() {
return html`<img
class="pf-c-page__header-tools-item pf-c-avatar pf-m-hidden pf-m-visible-on-xl"
src=${ifDefined(this.me?.user.avatar)}
alt="${msg("Avatar image")}"
/>`;
}
get userDisplayName() { get userDisplayName() {
return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay) return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay)
.with(UserDisplay.username, () => this.me?.user.username) .with(UserDisplay.username, () => this.me?.user.username)
@ -212,13 +206,17 @@ export class NavigationButtons extends AKElement {
</div> </div>
${this.renderImpersonation()} ${this.renderImpersonation()}
${this.userDisplayName != "" ${this.userDisplayName != ""
? html`<div class="pf-c-page__header-tools-group pf-m-hidden"> ? html`<div class="pf-c-page__header-tools-group">
<div class="pf-c-page__header-tools-item pf-m-visible-on-2xl"> <div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md">
${this.userDisplayName} ${this.userDisplayName}
</div> </div>
</div>` </div>`
: nothing} : nothing}
${this.renderAvatar()} <img
class="pf-c-avatar"
src=${ifDefined(this.me?.user.avatar)}
alt="${msg("Avatar image")}"
/>
</div>`; </div>`;
} }
} }

View File

@ -1,140 +1,165 @@
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { import { UIConfig } from "@goauthentik/common/ui/config";
StyleSheetInit, import { adaptCSS } from "@goauthentik/common/utils";
StyleSheetParent, import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
appendStyleSheet,
createStyleSheetUnsafe,
removeStyleSheet,
resolveStyleSheetParent,
} from "@goauthentik/common/stylesheets";
import {
CSSColorSchemeValue,
ResolvedUITheme,
UIThemeListener,
createUIThemeEffect,
formatColorScheme,
resolveUITheme,
} from "@goauthentik/common/theme";
import { type ThemedElement } from "@goauthentik/common/theme";
import { localized } from "@lit/localize"; import { localized } from "@lit/localize";
import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit"; import { LitElement, ReactiveElement } from "lit";
import { property } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css"; import AKGlobal from "@goauthentik/common/styles/authentik.css";
import OneDark from "@goauthentik/common/styles/one-dark.css"; import OneDark from "@goauthentik/common/styles/one-dark.css";
import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
import { UiThemeEnum } from "@goauthentik/api"; import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
// Re-export the theme helpers type AkInterface = HTMLElement & {
export { rootInterface } from "@goauthentik/common/theme"; getTheme: () => Promise<UiThemeEnum>;
brand?: CurrentBrand;
uiConfig?: UIConfig;
config?: Config;
get activeTheme(): UiThemeEnum | undefined;
};
export const rootInterface = <T extends AkInterface>(): T | undefined =>
(document.body.querySelector("[data-ak-interface-root]") as T) ?? undefined;
export const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)";
// Ensure themes are converted to a static instance of CSS Stylesheet, otherwise the
// when changing themes we might not remove the correct css stylesheet instance.
const _darkTheme = ensureCSSStyleSheet(ThemeDark);
@localized() @localized()
export class AKElement extends LitElement implements ThemedElement { export class AKElement extends LitElement {
//#region Properties _mediaMatcher?: MediaQueryList;
_mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void;
_activeTheme?: UiThemeEnum;
/** get activeTheme(): UiThemeEnum | undefined {
* The resolved theme of the current element. return this._activeTheme;
*
* @remarks
*
* Unlike the browser's current color scheme, this is a value that can be
* resolved to a specific theme, i.e. dark or light.
*/
@property({
attribute: "theme",
type: String,
reflect: true,
})
public activeTheme: ResolvedUITheme;
//#endregion
//#region Private Properties
readonly #preferredColorScheme: CSSColorSchemeValue;
#customCSSStyleSheet: CSSStyleSheet | null;
#darkThemeStyleSheet: CSSStyleSheet | null = null;
#themeAbortController: AbortController | null = null;
//#endregion
//#region Lifecycle
protected static finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
// Ensure all style sheets being passed are really style sheets.
const baseStyles: StyleSheetInit[] = [AKGlobal, OneDark];
if (!styles) return baseStyles.map(createStyleSheetUnsafe);
if (Array.isArray(styles)) {
return [
//---
...(styles as unknown as CSSResultOrNative[]),
...baseStyles,
].flatMap(createStyleSheetUnsafe);
}
return [styles, ...baseStyles].map(createStyleSheetUnsafe);
} }
constructor() { constructor() {
super(); super();
const { brand } = globalAK();
this.#preferredColorScheme = formatColorScheme(brand.uiTheme);
this.activeTheme = resolveUITheme(brand?.uiTheme);
this.#customCSSStyleSheet = brand?.brandingCustomCss
? createStyleSheetUnsafe(brand.brandingCustomCss)
: null;
} }
public disconnectedCallback(): void { setInitialStyles(root: DocumentOrShadowRoot) {
super.disconnectedCallback(); const styleRoot: DocumentOrShadowRoot = (
this.#themeAbortController?.abort(); "ShadyDOM" in window ? document : root
) as DocumentOrShadowRoot;
styleRoot.adoptedStyleSheets = adaptCSS([
...styleRoot.adoptedStyleSheets,
ensureCSSStyleSheet(AKGlobal),
ensureCSSStyleSheet(OneDark),
]);
this._initTheme(styleRoot);
this._initCustomCSS(styleRoot);
} }
#styleRoot?: StyleSheetParent; protected createRenderRoot() {
this.fixElementStyles();
#dispatchTheme: UIThemeListener = (nextUITheme) => { const root = super.createRenderRoot();
if (!this.#styleRoot) return; this.setInitialStyles(root as unknown as DocumentOrShadowRoot);
return root;
if (nextUITheme === UiThemeEnum.Dark) {
this.#darkThemeStyleSheet ||= createStyleSheetUnsafe(ThemeDark);
appendStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet);
this.activeTheme = UiThemeEnum.Dark;
} else if (this.#darkThemeStyleSheet) {
removeStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet);
this.#darkThemeStyleSheet = null;
this.activeTheme = UiThemeEnum.Light;
}
};
protected createRenderRoot(): HTMLElement | DocumentFragment {
const renderRoot = super.createRenderRoot();
this.#styleRoot = resolveStyleSheetParent(renderRoot);
if (this.#customCSSStyleSheet) {
console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`);
appendStyleSheet(this.#styleRoot, this.#customCSSStyleSheet);
}
this.#themeAbortController = new AbortController();
if (this.#preferredColorScheme === "dark") {
this.#dispatchTheme(UiThemeEnum.Dark);
} else if (this.#preferredColorScheme === "auto") {
createUIThemeEffect(this.#dispatchTheme, {
signal: this.#themeAbortController.signal,
});
}
return renderRoot;
} }
//#endregion async getTheme(): Promise<UiThemeEnum> {
return rootInterface()?.getTheme() || UiThemeEnum.Automatic;
}
fixElementStyles() {
// Ensure all style sheets being passed are really style sheets.
(this.constructor as typeof ReactiveElement).elementStyles = (
this.constructor as typeof ReactiveElement
).elementStyles.map(ensureCSSStyleSheet);
}
async _initTheme(root: DocumentOrShadowRoot): Promise<void> {
// Early activate theme based on media query to prevent light flash
// when dark is preferred
this._applyTheme(root, globalAK().brand.uiTheme);
this._applyTheme(root, await this.getTheme());
}
async _initCustomCSS(root: DocumentOrShadowRoot): Promise<void> {
const brand = globalAK().brand;
if (!brand) {
return;
}
const sheet = await new CSSStyleSheet().replace(brand.brandingCustomCss);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
}
_applyTheme(root: DocumentOrShadowRoot, theme?: UiThemeEnum): void {
if (!theme) {
theme = UiThemeEnum.Automatic;
}
if (theme === UiThemeEnum.Automatic) {
// Create a media matcher to automatically switch the theme depending on
// prefers-color-scheme
if (!this._mediaMatcher) {
this._mediaMatcher = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT);
this._mediaMatcherHandler = (ev?: MediaQueryListEvent) => {
const theme =
ev?.matches || this._mediaMatcher?.matches
? UiThemeEnum.Light
: UiThemeEnum.Dark;
this._activateTheme(theme, root);
};
this._mediaMatcherHandler(undefined);
this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler);
}
return;
} else if (this._mediaMatcher && this._mediaMatcherHandler) {
// Theme isn't automatic and we have a matcher configured, remove the matcher
// to prevent changes
this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler);
this._mediaMatcher = undefined;
}
this._activateTheme(theme, root);
}
static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined {
if (theme === UiThemeEnum.Dark) {
return _darkTheme;
}
return undefined;
}
/**
* Directly activate a given theme, accepts multiple document/ShadowDOMs to apply the stylesheet
* to. The stylesheets are applied to each DOM in order. Does nothing if the given theme is already active.
*/
_activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]) {
if (theme === this._activeTheme) {
return;
}
// Make sure we only get to this callback once we've picked a concise theme choice
this.dispatchEvent(
new CustomEvent(EVENT_THEME_CHANGE, {
bubbles: true,
composed: true,
detail: theme,
}),
);
this.setAttribute("theme", theme);
const stylesheet = AKElement.themeToStylesheet(theme);
const oldStylesheet = AKElement.themeToStylesheet(this._activeTheme);
roots.forEach((root) => {
if (stylesheet) {
root.adoptedStyleSheets = [
...root.adoptedStyleSheets,
ensureCSSStyleSheet(stylesheet),
];
}
if (oldStylesheet) {
root.adoptedStyleSheets = root.adoptedStyleSheets.filter(
(v) => v !== oldStylesheet,
);
}
});
this._activeTheme = theme;
this.requestUpdate();
}
} }

View File

@ -1,6 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { ThemedElement } from "@goauthentik/common/theme";
import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts";
import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
@ -10,12 +9,14 @@ import type { ReactiveController } from "lit";
import type { CurrentBrand } from "@goauthentik/api"; import type { CurrentBrand } from "@goauthentik/api";
import { CoreApi } from "@goauthentik/api"; import { CoreApi } from "@goauthentik/api";
import type { AkInterface } from "./Interface";
export class BrandContextController implements ReactiveController { export class BrandContextController implements ReactiveController {
host!: ReactiveElementHost<ThemedElement>; host!: ReactiveElementHost<AkInterface>;
context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; context!: ContextProvider<{ __context__: CurrentBrand | undefined }>;
constructor(host: ReactiveElementHost<ThemedElement>) { constructor(host: ReactiveElementHost<AkInterface>) {
this.host = host; this.host = host;
this.context = new ContextProvider(this.host, { this.context = new ContextProvider(this.host, {
context: authentikBrandContext, context: authentikBrandContext,

View File

@ -1,7 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { ThemedElement } from "@goauthentik/common/theme";
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
@ -11,12 +10,14 @@ import type { ReactiveController } from "lit";
import type { Config } from "@goauthentik/api"; import type { Config } from "@goauthentik/api";
import { RootApi } from "@goauthentik/api"; import { RootApi } from "@goauthentik/api";
import type { AkInterface } from "./Interface";
export class ConfigContextController implements ReactiveController { export class ConfigContextController implements ReactiveController {
host!: ReactiveElementHost<ThemedElement>; host!: ReactiveElementHost<AkInterface>;
context!: ContextProvider<{ __context__: Config | undefined }>; context!: ContextProvider<{ __context__: Config | undefined }>;
constructor(host: ReactiveElementHost<ThemedElement>) { constructor(host: ReactiveElementHost<AkInterface>) {
this.host = host; this.host = host;
this.context = new ContextProvider(this.host, { this.context = new ContextProvider(this.host, {
context: authentikConfigContext, context: authentikConfigContext,

View File

@ -1,78 +1,107 @@
import { import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
appendStyleSheet,
createStyleSheetUnsafe,
resolveStyleSheetParent,
} from "@goauthentik/common/stylesheets";
import { ThemedElement } from "@goauthentik/common/theme";
import { UIConfig } from "@goauthentik/common/ui/config";
import { AKElement } from "@goauthentik/elements/Base";
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController"; import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController";
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
import { state } from "lit/decorators.js"; import { state } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api"; import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
import { UiThemeEnum } from "@goauthentik/api";
import { AKElement, rootInterface } from "../Base";
import { BrandContextController } from "./BrandContextController"; import { BrandContextController } from "./BrandContextController";
import { ConfigContextController } from "./ConfigContextController"; import { ConfigContextController } from "./ConfigContextController";
import { EnterpriseContextController } from "./EnterpriseContextController"; import { EnterpriseContextController } from "./EnterpriseContextController";
export type AkInterface = HTMLElement & {
getTheme: () => Promise<UiThemeEnum>;
brand?: CurrentBrand;
uiConfig?: UIConfig;
config?: Config;
};
const brandContext = Symbol("brandContext");
const configContext = Symbol("configContext"); const configContext = Symbol("configContext");
const modalController = Symbol("modalController"); const modalController = Symbol("modalController");
const versionContext = Symbol("versionContext"); const versionContext = Symbol("versionContext");
export abstract class Interface extends AKElement implements ThemedElement { export class Interface extends AKElement implements AkInterface {
protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase); [brandContext]!: BrandContextController;
[configContext]: ConfigContextController; [configContext]!: ConfigContextController;
[modalController]: ModalOrchestrationController; [modalController]!: ModalOrchestrationController;
@state() @state()
public config?: Config; uiConfig?: UIConfig;
@state() @state()
public brand?: CurrentBrand; config?: Config;
@state()
brand?: CurrentBrand;
constructor() { constructor() {
super(); super();
const styleParent = resolveStyleSheetParent(document); document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)];
this._initContexts();
this.dataset.akInterfaceRoot = "true";
}
this.dataset.akInterfaceRoot = this.tagName.toLowerCase(); _initContexts() {
this[brandContext] = new BrandContextController(this);
appendStyleSheet(styleParent, Interface.PFBaseStyleSheet);
this.addController(new BrandContextController(this));
this[configContext] = new ConfigContextController(this); this[configContext] = new ConfigContextController(this);
this[modalController] = new ModalOrchestrationController(this); this[modalController] = new ModalOrchestrationController(this);
} }
_activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]): void {
if (theme === this._activeTheme) {
return;
}
console.debug(
`authentik/interface[${rootInterface()?.tagName.toLowerCase()}]: Enabling theme ${theme}`,
);
// Special case for root interfaces, as they need to modify the global document CSS too
// Instead of calling ._activateTheme() twice, we insert the root document in the call
// since multiple calls to ._activateTheme() would not do anything after the first call
// as the theme is already enabled.
roots.unshift(document as unknown as DocumentOrShadowRoot);
super._activateTheme(theme, ...roots);
}
async getTheme(): Promise<UiThemeEnum> {
if (!this.uiConfig) {
this.uiConfig = await uiConfig();
}
return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
}
} }
export interface AkAuthenticatedInterface extends ThemedElement { export type AkAuthenticatedInterface = AkInterface & {
licenseSummary?: LicenseSummary; licenseSummary?: LicenseSummary;
version?: Version; version?: Version;
} };
const enterpriseContext = Symbol("enterpriseContext"); const enterpriseContext = Symbol("enterpriseContext");
export class AuthenticatedInterface extends Interface implements AkAuthenticatedInterface { export class AuthenticatedInterface extends Interface {
[enterpriseContext]!: EnterpriseContextController; [enterpriseContext]!: EnterpriseContextController;
[versionContext]!: VersionContextController; [versionContext]!: VersionContextController;
@state() @state()
public uiConfig?: UIConfig; licenseSummary?: LicenseSummary;
@state() @state()
public licenseSummary?: LicenseSummary; version?: Version;
@state()
public version?: Version;
constructor() { constructor() {
super(); super();
}
_initContexts(): void {
super._initContexts();
this[enterpriseContext] = new EnterpriseContextController(this); this[enterpriseContext] = new EnterpriseContextController(this);
this[versionContext] = new VersionContextController(this); this[versionContext] = new VersionContextController(this);
} }

View File

@ -5,23 +5,20 @@ import {
} from "@goauthentik/common/constants"; } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { currentInterface } from "@goauthentik/common/sentry"; import { currentInterface } from "@goauthentik/common/sentry";
import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config"; import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import "@goauthentik/components/ak-nav-buttons"; import "@goauthentik/components/ak-nav-buttons";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
import { themeImage } from "@goauthentik/elements/utils/images";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css"; import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css";
@ -29,52 +26,34 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SessionUser } from "@goauthentik/api"; import { SessionUser } from "@goauthentik/api";
//#region Page Navbar @customElement("ak-page-header")
export class PageHeader extends WithBrandConfig(AKElement) {
export interface PageNavbarDetails { @property()
header?: string;
description?: string;
icon?: string; icon?: string;
iconImage?: boolean;
}
/** @property({ type: Boolean })
* A global navbar component at the top of the page. iconImage = false;
*
* Internally, this component listens for the `ak-page-header` event, which is
* dispatched by the `ak-page-header` component.
*/
@customElement("ak-page-navbar")
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
//#region Static Properties
private static elementRef: AKPageNavbar | null = null; @property()
header = "";
static readonly setNavbarDetails = (detail: Partial<PageNavbarDetails>): void => { @property()
const { elementRef } = AKPageNavbar; description?: string;
if (!elementRef) {
console.debug(
`ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`,
);
return;
}
const { header, description, icon, iconImage } = detail; @property({ type: Boolean })
hasIcon = true;
elementRef.header = header; @state()
elementRef.description = description; me?: SessionUser;
elementRef.icon = icon;
elementRef.iconImage = iconImage || false; @state()
elementRef.hasIcon = !!icon; uiConfig!: UIConfig;
};
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
PFBase, PFBase,
PFButton, PFButton,
PFPage, PFPage,
PFDrawer,
PFNotificationBadge, PFNotificationBadge,
PFContent, PFContent,
PFAvatar, PFAvatar,
@ -84,404 +63,143 @@ export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavb
position: sticky; position: sticky;
top: 0; top: 0;
z-index: var(--pf-global--ZIndex--lg); z-index: var(--pf-global--ZIndex--lg);
--pf-c-page__header-tools--MarginRight: 0;
--ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem);
--ak-brand-background-color: var(
--pf-c-page__sidebar--m-light--BackgroundColor
);
--host-navbar-height: var(--ak-c-page-header--height, 7.5rem);
} }
.bar {
:host([theme="dark"]) {
--ak-brand-background-color: var(--pf-c-page__sidebar--BackgroundColor);
--pf-c-page__sidebar--BackgroundColor: var(--ak-dark-background-light);
color: var(--ak-dark-foreground);
}
navbar {
border-bottom: var(--pf-global--BorderWidth--sm); border-bottom: var(--pf-global--BorderWidth--sm);
border-bottom-style: solid; border-bottom-style: solid;
border-bottom-color: var(--pf-global--BorderColor--100); border-bottom-color: var(--pf-global--BorderColor--100);
background-color: var(--pf-c-page--BackgroundColor);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
min-height: 114px;
display: grid; max-height: 114px;
row-gap: var(--pf-global--spacer--sm); background-color: var(--pf-c-page--BackgroundColor);
column-gap: var(--pf-global--spacer--sm);
grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto;
grid-template-rows: auto auto;
grid-template-areas:
"brand toggle primary secondary"
"brand toggle description secondary";
@media (min-width: 426px) {
height: var(--host-navbar-height);
}
@media (max-width: 768px) {
row-gap: var(--pf-global--spacer--xs);
align-items: center;
grid-template-areas:
"toggle primary secondary"
"toggle description description";
justify-content: space-between;
width: 100%;
}
} }
.pf-c-page__main-section.pf-m-light {
.items { background-color: transparent;
display: block;
&.primary {
grid-column: primary;
grid-row: primary / description;
align-content: center;
padding-block: var(--pf-global--spacer--md);
@media (min-width: 426px) {
&.block-sibling {
padding-block-end: 0;
grid-row: primary;
}
}
@media (max-width: 768px) {
padding-block: var(--pf-global--spacer--sm);
}
.accent-icon {
height: 1em;
width: 1em;
@media (max-width: 768px) {
display: none;
}
}
}
&.page-description {
grid-area: description;
margin-block-end: var(--pf-global--spacer--md);
display: box;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
box-orient: vertical;
-webkit-box-orient: vertical;
overflow: hidden;
@media (max-width: 425px) {
display: none;
}
@media (min-width: 769px) {
text-wrap: balance;
}
}
&.secondary {
grid-area: secondary;
flex: 0 0 auto;
justify-self: end;
padding-block: var(--pf-global--spacer--sm);
padding-inline-end: var(--pf-global--spacer--sm);
@media (min-width: 769px) {
align-content: center;
padding-block: var(--pf-global--spacer--md);
padding-inline-end: var(--pf-global--spacer--xl);
}
}
} }
.pf-c-page__main-section {
.brand { flex-grow: 1;
grid-area: brand; flex-shrink: 1;
background-color: var(--ak-brand-background-color);
height: 100%;
width: var(--pf-c-page__sidebar--Width);
align-items: center;
padding-inline: var(--pf-global--spacer--sm);
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
&.pf-m-collapsed {
display: none;
}
@media (max-width: 1199px) {
display: none;
}
} }
img.pf-icon {
.sidebar-trigger { max-height: 24px;
grid-area: toggle;
height: 100%;
} }
.logo {
flex: 0 0 auto;
height: var(--ak-brand-logo-height);
& img {
height: 100%;
}
}
.sidebar-trigger, .sidebar-trigger,
.notification-trigger { .notification-trigger {
font-size: 1.5rem; font-size: 24px;
} }
.notification-trigger.has-notifications { .notification-trigger.has-notifications {
color: var(--pf-global--active-color--100); color: var(--pf-global--active-color--100);
} }
.page-title {
display: flex;
gap: var(--pf-global--spacer--xs);
}
h1 { h1 {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center !important; align-items: center !important;
} }
`, .pf-c-page__header-tools {
]; flex-shrink: 0;
} }
.pf-c-page__header-tools-group {
//#endregion height: 100%;
}
//#region Properties :host([theme="dark"]) .pf-c-page__header-tools {
color: var(--ak-dark-foreground) !important;
@property({ type: String })
icon?: string;
@property({ type: Boolean })
iconImage = false;
@property({ type: String })
header?: string;
@property({ type: String })
description?: string;
@property({ type: Boolean })
hasIcon = true;
@property({ type: Boolean })
open = true;
@state()
session?: SessionUser;
@state()
uiConfig!: UIConfig;
//#endregion
//#region Private Methods
#setTitle(header?: string) {
const currentIf = currentInterface();
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
if (currentIf === "admin") {
title = `${msg("Admin")} - ${title}`;
}
// Prepend the header to the title
if (header) {
title = `${header} - ${title}`;
}
document.title = title;
}
#toggleSidebar() {
this.open = !this.open;
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}
//#endregion
//#region Lifecycle
public connectedCallback(): void {
super.connectedCallback();
AKPageNavbar.elementRef = this;
window.addEventListener(EVENT_WS_MESSAGE, () => {
this.firstUpdated();
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
AKPageNavbar.elementRef = null;
}
public async firstUpdated() {
this.session = await me();
this.uiConfig = getConfigForUser(this.session.user);
this.uiConfig.navbar.userDisplay = UserDisplay.none;
}
willUpdate() {
// Always update title, even if there's no header value set,
// as in that case we still need to return to the generic title
this.#setTitle(this.header);
}
//#endregion
//#region Render
renderIcon() {
if (this.icon) {
if (this.iconImage && !this.icon.startsWith("fa://")) {
return html`<img class="accent-icon pf-icon" src="${this.icon}" alt="page icon" />`;
}
const icon = this.icon.replaceAll("fa://", "fa ");
return html`<i class="accent-icon ${icon}"></i>`;
}
return nothing;
}
render(): TemplateResult {
return html`<navbar aria-label="Main" class="navbar">
<aside class="brand ${this.open ? "" : "pf-m-collapsed"}">
<a href="#/">
<div class="logo">
<img
src=${themeImage(
this.brand?.brandingLogo ?? DefaultBrand.brandingLogo,
)}
alt="${msg("authentik Logo")}"
loading="lazy"
/>
</div>
</a>
</aside>
<button
class="sidebar-trigger pf-c-button pf-m-plain"
@click=${this.#toggleSidebar}
aria-label=${msg("Toggle sidebar")}
aria-expanded=${this.open ? "true" : "false"}
>
<i class="fas fa-bars"></i>
</button>
<section
class="items primary pf-c-content ${this.description ? "block-sibling" : ""}"
>
<h1 class="page-title">
${this.hasIcon
? html`<slot name="icon">${this.renderIcon()}</slot>`
: nothing}
${this.header}
</h1>
</section>
${this.description
? html`<section class="items page-description pf-c-content">
<p>${this.description}</p>
</section>`
: nothing}
<section class="items secondary">
<div class="pf-c-page__header-tools-group">
<ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.session}>
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${globalAK().api.base}if/user/"
slot="extra"
>
${msg("User interface")}
</a>
</ak-nav-buttons>
</div>
</section>
</navbar>
<slot></slot>`;
}
//#endregion
}
//#endregion
//#region Page Header
/**
* A page header component, used to display the page title and description.
*
* Internally, this component dispatches the `ak-page-header` event, which is
* listened to by the `ak-page-navbar` component.
*
* @singleton
*/
@customElement("ak-page-header")
export class AKPageHeader extends LitElement implements PageNavbarDetails {
@property({ type: String })
header?: string;
@property({ type: String })
description?: string;
@property({ type: String })
icon?: string;
@property({ type: Boolean })
iconImage = false;
static get styles(): CSSResult[] {
return [
css`
:host {
display: none;
} }
`, `,
]; ];
} }
connectedCallback(): void { constructor() {
super.connectedCallback(); super();
window.addEventListener(EVENT_WS_MESSAGE, () => {
AKPageNavbar.setNavbarDetails({ this.firstUpdated();
header: this.header,
description: this.description,
icon: this.icon,
iconImage: this.iconImage,
}); });
} }
updated(): void { async firstUpdated() {
AKPageNavbar.setNavbarDetails({ this.me = await me();
header: this.header, this.uiConfig = await uiConfig();
description: this.description, this.uiConfig.navbar.userDisplay = UserDisplay.none;
icon: this.icon, }
iconImage: this.iconImage,
}); setTitle(header?: string) {
const currentIf = currentInterface();
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
if (currentIf === "admin") {
title = `${msg("Admin")} - ${title}`;
}
// Prepend the header to the title
if (header !== undefined && header !== "") {
title = `${header} - ${title}`;
}
document.title = title;
}
willUpdate() {
// Always update title, even if there's no header value set,
// as in that case we still need to return to the generic title
this.setTitle(this.header);
}
renderIcon() {
if (this.icon) {
if (this.iconImage && !this.icon.startsWith("fa://")) {
return html`<img class="pf-icon" src="${this.icon}" alt="page icon" />`;
}
const icon = this.icon.replaceAll("fa://", "fa ");
return html`<i class=${icon}></i>`;
}
return nothing;
}
render(): TemplateResult {
return html`<div class="bar">
<button
class="sidebar-trigger pf-c-button pf-m-plain"
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
>
<i class="fas fa-bars"></i>
</button>
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
${this.hasIcon
? html`<slot name="icon">${this.renderIcon()}</slot>&nbsp;`
: nothing}
<slot name="header">${this.header}</slot>
</h1>
${this.description ? html`<p>${this.description}</p>` : html``}
</div>
</section>
<div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
<ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}>
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${globalAK().api.base}if/user/"
slot="extra"
>
${msg("User interface")}
</a>
</ak-nav-buttons>
</div>
</div>
</div>`;
} }
} }
//#endregion
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ak-page-header": AKPageHeader; "ak-page-header": PageHeader;
"ak-page-navbar": AKPageNavbar;
} }
} }

View File

@ -22,7 +22,6 @@ export class Sidebar extends AKElement {
css` css`
:host { :host {
z-index: 100; z-index: 100;
--pf-c-page__sidebar--Transition: 0 !important;
} }
.pf-c-nav__link.pf-m-current::after, .pf-c-nav__link.pf-m-current::after,
.pf-c-nav__link.pf-m-current:hover::after, .pf-c-nav__link.pf-m-current:hover::after,
@ -36,7 +35,10 @@ export class Sidebar extends AKElement {
.pf-c-nav__section + .pf-c-nav__section { .pf-c-nav__section + .pf-c-nav__section {
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm); --pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
} }
.pf-c-nav__list .sidebar-brand {
max-height: 82px;
margin-bottom: -0.5rem;
}
nav { nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -68,6 +70,7 @@ export class Sidebar extends AKElement {
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}" class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
aria-label=${msg("Global")} aria-label=${msg("Global")}
> >
<ak-sidebar-brand></ak-sidebar-brand>
<ul class="pf-c-nav__list"> <ul class="pf-c-nav__list">
<slot></slot> <slot></slot>
</ul> </ul>

View File

@ -1,3 +1,17 @@
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
import { themeImage } from "@goauthentik/elements/utils/images";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGlobal from "@patternfly/patternfly/patternfly-base.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CurrentBrand, UiThemeEnum } from "@goauthentik/api"; import { CurrentBrand, UiThemeEnum } from "@goauthentik/api";
// If the viewport is wider than MIN_WIDTH, the sidebar // If the viewport is wider than MIN_WIDTH, the sidebar
@ -14,3 +28,79 @@ export const DefaultBrand: CurrentBrand = {
matchedDomain: "", matchedDomain: "",
defaultLocale: "", defaultLocale: "",
}; };
@customElement("ak-sidebar-brand")
export class SidebarBrand extends WithBrandConfig(AKElement) {
static get styles(): CSSResult[] {
return [
PFBase,
PFGlobal,
PFPage,
PFButton,
css`
:host {
display: flex;
flex-direction: row;
align-items: center;
height: 114px;
min-height: 114px;
border-bottom: var(--pf-global--BorderWidth--sm);
border-bottom-style: solid;
border-bottom-color: var(--pf-global--BorderColor--100);
}
.pf-c-brand img {
padding: 0 0.5rem;
height: 42px;
}
button.pf-c-button.sidebar-trigger {
background-color: transparent;
border-radius: 0px;
height: 100%;
color: var(--ak-dark-foreground);
}
`,
];
}
constructor() {
super();
window.addEventListener("resize", () => {
this.requestUpdate();
});
}
render(): TemplateResult {
return html` ${window.innerWidth <= MIN_WIDTH
? html`
<button
class="sidebar-trigger pf-c-button"
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
>
<i class="fas fa-bars"></i>
</button>
`
: html``}
<a href="#/" class="pf-c-page__header-brand-link">
<div class="pf-c-brand ak-brand">
<img
src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)}
alt="${msg("authentik Logo")}"
loading="lazy"
/>
</div>
</a>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-sidebar-brand": SidebarBrand;
}
}

View File

@ -1,21 +1,19 @@
import {
appendStyleSheet,
assertAdoptableStyleSheetParent,
createStyleSheetUnsafe,
} from "@goauthentik/common/stylesheets.js";
import { TemplateResult, render as litRender } from "lit"; import { TemplateResult, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css"; import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ensureCSSStyleSheet } from "../utils/ensureCSSStyleSheet.js";
// A special version of render that ensures our style sheets will always be available // A special version of render that ensures our style sheets will always be available
// to all elements under test. Ensures they look right during testing, and that any // to all elements under test. Ensures they look right during testing, and that any
// CSS-based checks for visibility will return correct values. // CSS-based checks for visibility will return correct values.
export const render = (body: TemplateResult) => { export const render = (body: TemplateResult) => {
assertAdoptableStyleSheetParent(document); document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
appendStyleSheet(document, ...[PFBase, AKGlobal].map(createStyleSheetUnsafe)); ensureCSSStyleSheet(PFBase),
ensureCSSStyleSheet(AKGlobal),
];
return litRender(body, document.body); return litRender(body, document.body);
}; };

View File

@ -1,14 +1,9 @@
import { AKElement } from "@goauthentik/elements/Base";
import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit"; import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit";
import "lit"; import "lit";
/** export type ReactiveElementHost<T = AKElement> = Partial<ReactiveControllerHost> & T;
* A custom element which may be used as a host for a ReactiveController.
*
* @remarks
*
* This type is derived from an internal type in Lit.
*/
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & T> & HTMLElement;
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement; export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;

View File

@ -0,0 +1,35 @@
import { CSSResult, unsafeCSS } from "lit";
const supportsAdoptingStyleSheets: boolean =
window.ShadowRoot &&
(window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) &&
"adoptedStyleSheets" in Document.prototype &&
"replace" in CSSStyleSheet.prototype;
function stringToStylesheet(css: string) {
if (supportsAdoptingStyleSheets) {
const sheet = unsafeCSS(css).styleSheet;
if (sheet === undefined) {
throw new Error(
`CSS processing error: undefined stylesheet from string. Source: ${css}`,
);
}
return sheet;
}
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
return sheet;
}
function cssResultToStylesheet(css: CSSResult) {
const sheet = css.styleSheet;
return sheet ? sheet : stringToStylesheet(css.toString());
}
export const ensureCSSStyleSheet = (css: string | CSSStyleSheet | CSSResult): CSSStyleSheet =>
css instanceof CSSResult
? cssResultToStylesheet(css)
: typeof css === "string"
? stringToStylesheet(css)
: css;

View File

@ -1,55 +0,0 @@
/**
* @file IFrame Utilities
*/
interface IFrameLoadResult {
contentWindow: Window;
contentDocument: Document;
}
export function pluckIFrameContent(iframe: HTMLIFrameElement) {
const contentWindow = iframe.contentWindow;
const contentDocument = iframe.contentDocument;
if (!contentWindow) {
throw new Error("Iframe contentWindow is not accessible");
}
if (!contentDocument) {
throw new Error("Iframe contentDocument is not accessible");
}
return {
contentWindow,
contentDocument,
};
}
export function resolveIFrameContent(iframe: HTMLIFrameElement): Promise<IFrameLoadResult> {
if (iframe.contentDocument?.readyState === "complete") {
return Promise.resolve(pluckIFrameContent(iframe));
}
return new Promise((resolve) => {
iframe.addEventListener("load", () => resolve(pluckIFrameContent(iframe)), { once: true });
});
}
/**
* Creates a minimal HTML wrapper for an iframe.
*
* @deprecated Use the `contentDocument.body` directly instead.
*/
export function createIFrameHTMLWrapper(bodyContent: string): string {
const html = String.raw;
return html`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body style="display:flex;flex-direction:row;justify-content:center;">
${bodyContent}
</body>
</html>`;
}

View File

@ -1,8 +1,13 @@
import { resolveUITheme } from "@goauthentik/common/theme"; import { QUERY_MEDIA_COLOR_LIGHT, rootInterface } from "@goauthentik/elements/Base";
import { rootInterface } from "@goauthentik/elements/Base";
import { UiThemeEnum } from "@goauthentik/api";
export function themeImage(rawPath: string) { export function themeImage(rawPath: string) {
const enabledTheme = rootInterface()?.activeTheme || resolveUITheme(); let enabledTheme = rootInterface()?.activeTheme;
if (!enabledTheme || enabledTheme === UiThemeEnum.Automatic) {
enabledTheme = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches
? UiThemeEnum.Light
: UiThemeEnum.Dark;
}
return rawPath.replaceAll("%(theme)s", enabledTheme); return rawPath.replaceAll("%(theme)s", enabledTheme);
} }

View File

@ -46,6 +46,7 @@ import {
FlowsApi, FlowsApi,
ResponseError, ResponseError,
ShellChallenge, ShellChallenge,
UiThemeEnum,
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-flow-executor") @customElement("ak-flow-executor")
@ -199,6 +200,10 @@ export class FlowExecutor extends Interface implements StageHost {
}); });
} }
async getTheme(): Promise<UiThemeEnum> {
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
}
async submit( async submit(
payload?: FlowChallengeResponseRequest, payload?: FlowChallengeResponseRequest,
options?: SubmitOptions, options?: SubmitOptions,

View File

@ -1,4 +1,4 @@
import { BrandedHTMLPolicy, sanitizeHTML } from "@goauthentik/common/purify"; import { purify } from "@goauthentik/common/purify";
import { AKElement } from "@goauthentik/elements/Base.js"; import { AKElement } from "@goauthentik/elements/Base.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@ -21,6 +21,8 @@ const styles = css`
} }
`; `;
const poweredBy: FooterLink = { name: msg("Powered by authentik"), href: null };
@customElement("ak-brand-links") @customElement("ak-brand-links")
export class BrandLinks extends AKElement { export class BrandLinks extends AKElement {
static get styles() { static get styles() {
@ -31,21 +33,13 @@ export class BrandLinks extends AKElement {
links: FooterLink[] = []; links: FooterLink[] = [];
render() { render() {
const links = [...(this.links ?? [])]; const links = [...(this.links ?? []), poweredBy];
return html` <ul class="pf-c-list pf-m-inline"> return html` <ul class="pf-c-list pf-m-inline">
${map(links, (link) => { ${map(links, (link) =>
const children = sanitizeHTML(BrandedHTMLPolicy, link.name); link.href
? purify(html`<li><a href="${link.href}">${link.name}</a></li>`)
if (link.href) { : html`<li><span>${link.name}</span></li>`,
return html`<li><a href="${link.href}">${children}</a></li>`; )}
}
return html`<li>
<span> ${children} </span>
</li>`;
})}
<li><span>${msg("Powered by authentik")}</span></li>
</ul>`; </ul>`;
} }
} }

View File

@ -1,16 +1,15 @@
/// <reference types="@hcaptcha/types"/> ///<reference types="@hcaptcha/types"/>
/// <reference types="turnstile-types"/> import { renderStatic } from "@goauthentik/common/purify";
import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import { akEmptyState } from "@goauthentik/elements/EmptyState"; import { akEmptyState } from "@goauthentik/elements/EmptyState";
import { bound } from "@goauthentik/elements/decorators/bound"; import { bound } from "@goauthentik/elements/decorators/bound";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe";
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js"; import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
import { randomId } from "@goauthentik/elements/utils/randomId"; import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic"; import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base"; import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern"; import { P, match } from "ts-pattern";
import type * as _ from "turnstile-types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -57,36 +56,40 @@ type CaptchaHandler = {
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some // a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden // margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
// rendering. // rendering.
function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult {
return html` ${children}
<script>
new ResizeObserver((entries) => {
const height =
document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
window.parent.postMessage({ const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
message: "resize", html`<!doctype html>
source: "goauthentik.io", <head>
context: "flow-executor", <html>
size: { height }, <body style="display:flex;flex-direction:row;justify-content:center;">
}); ${captchaElement}
}).observe(document.querySelector(".ak-captcha-container")); <script>
</script> new ResizeObserver((entries) => {
const height =
<script src=${challengeURL}></script> document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
<script> window.parent.postMessage({
function callback(token) { message: "resize",
window.parent.postMessage({ source: "goauthentik.io",
message: "captcha", context: "flow-executor",
source: "goauthentik.io", size: { height },
context: "flow-executor", });
token, }).observe(document.querySelector(".ak-captcha-container"));
}); </script>
} <script src=${challengeUrl}></script>
</script>`; <script>
} function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token: token,
});
}
</script>
</body>
</html>
</head>`;
@customElement("ak-stage-captcha") @customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> { export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
@ -302,25 +305,11 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
} }
async renderFrame(captchaElement: TemplateResult) { async renderFrame(captchaElement: TemplateResult) {
const { contentDocument } = this.captchaFrame || {}; this.captchaFrame.contentWindow?.document.open();
this.captchaFrame.contentWindow?.document.write(
if (!contentDocument) { await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
console.debug(
"authentik/stages/captcha: unable to render captcha frame, no contentDocument",
);
return;
}
contentDocument.open();
contentDocument.write(
createIFrameHTMLWrapper(
renderStaticHTMLUnsafe(iframeTemplate(captchaElement, this.challenge.jsUrl)),
),
); );
this.captchaFrame.contentWindow?.document.close();
contentDocument.close();
} }
renderBody() { renderBody() {

View File

@ -3,6 +3,7 @@ import "rapidoc";
import { CSRFHeaderName } from "@goauthentik/common/api/config"; import { CSRFHeaderName } from "@goauthentik/common/api/config";
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { first, getCookie } from "@goauthentik/common/utils"; import { first, getCookie } from "@goauthentik/common/utils";
import { Interface } from "@goauthentik/elements/Interface"; import { Interface } from "@goauthentik/elements/Interface";
import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/ak-locale-context";
@ -61,6 +62,10 @@ export class APIBrowser extends Interface {
); );
} }
async getTheme(): Promise<UiThemeEnum> {
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
}
render(): TemplateResult { render(): TemplateResult {
return html` return html`
<ak-locale-context> <ak-locale-context>

View File

@ -1,3 +1,4 @@
import { globalAK } from "@goauthentik/common/global";
import { Interface } from "@goauthentik/elements/Interface"; import { Interface } from "@goauthentik/elements/Interface";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@ -9,6 +10,8 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css"; import PFSpinner from "@patternfly/patternfly/components/Spinner/spinner.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { UiThemeEnum } from "@goauthentik/api";
@customElement("ak-loading") @customElement("ak-loading")
export class Loading extends Interface { export class Loading extends Interface {
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
@ -25,7 +28,7 @@ export class Loading extends Interface {
]; ];
} }
registerContexts(): void { _initContexts(): void {
// Stub function to avoid making API requests for things we don't need. The `Interface` base class loads // Stub function to avoid making API requests for things we don't need. The `Interface` base class loads
// a bunch of data that is used globally by various things, however this is an interface that is shown // a bunch of data that is used globally by various things, however this is an interface that is shown
// very briefly and we don't need any of that data. // very briefly and we don't need any of that data.
@ -35,6 +38,10 @@ export class Loading extends Interface {
// Stub function to avoid fetching custom CSS. // Stub function to avoid fetching custom CSS.
} }
async getTheme(): Promise<UiThemeEnum> {
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
}
render(): TemplateResult { render(): TemplateResult {
return html` <section return html` <section
class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl" class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"

View File

@ -1,9 +1,18 @@
import { FlowExecutor } from "@goauthentik/flow/FlowExecutor"; import { FlowExecutor } from "@goauthentik/flow/FlowExecutor";
import { customElement } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { UiThemeEnum } from "@goauthentik/api";
@customElement("ak-storybook-interface-flow") @customElement("ak-storybook-interface-flow")
export class StoryFlowInterface extends FlowExecutor {} export class StoryFlowInterface extends FlowExecutor {
@property()
storyTheme: UiThemeEnum = UiThemeEnum.Dark;
async getTheme(): Promise<UiThemeEnum> {
return this.storyTheme;
}
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@ -1,9 +1,18 @@
import { Interface } from "@goauthentik/elements/Interface"; import { Interface } from "@goauthentik/elements/Interface";
import { customElement } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { UiThemeEnum } from "@goauthentik/api";
@customElement("ak-storybook-interface") @customElement("ak-storybook-interface")
export class StoryInterface extends Interface {} export class StoryInterface extends Interface {
@property()
storyTheme: UiThemeEnum = UiThemeEnum.Dark;
async getTheme(): Promise<UiThemeEnum> {
return this.storyTheme;
}
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@ -6,7 +6,7 @@ import {
} from "@goauthentik/common/constants"; } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { configureSentry } from "@goauthentik/common/sentry"; import { configureSentry } from "@goauthentik/common/sentry";
import { UIConfig, getConfigForUser } from "@goauthentik/common/ui/config"; import { UIConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws"; import { WebsocketClient } from "@goauthentik/common/ws";
import "@goauthentik/components/ak-nav-buttons"; import "@goauthentik/components/ak-nav-buttons";
@ -292,7 +292,6 @@ export class UserInterface extends AuthenticatedInterface {
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
@ -302,7 +301,6 @@ export class UserInterface extends AuthenticatedInterface {
window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer); window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer); window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails); window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
super.disconnectedCallback(); super.disconnectedCallback();
} }
@ -321,10 +319,8 @@ export class UserInterface extends AuthenticatedInterface {
} }
fetchConfigurationDetails() { fetchConfigurationDetails() {
me().then((session: SessionUser) => { me().then((me: SessionUser) => {
this.me = session; this.me = me;
this.uiConfig = getConfigForUser(session.user);
new EventsApi(DEFAULT_CONFIG) new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsList({ .eventsNotificationsList({
seen: false, seen: false,
@ -338,16 +334,12 @@ export class UserInterface extends AuthenticatedInterface {
}); });
} }
get isFullyConfigured() {
return Boolean(this.uiConfig && this.me);
}
render() { render() {
if (!this.me) { if (!this.isFullyConfigured) {
console.debug(`authentik/user/UserInterface: waiting for user session to be available`);
return nothing;
}
if (!this.uiConfig) {
console.debug(`authentik/user/UserInterface: waiting for UI config to be available`);
return nothing; return nothing;
} }

2106
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@
"docusaurus-theme-openapi-docs": "4.3.4", "docusaurus-theme-openapi-docs": "4.3.4",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^18.3.1", "react": "^19.1.0",
"react-before-after-slider-component": "^1.1.8", "react-before-after-slider-component": "^1.1.8",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-feather": "^2.0.10", "react-feather": "^2.0.10",
@ -58,7 +58,7 @@
"@docusaurus/module-type-aliases": "^3.3.2", "@docusaurus/module-type-aliases": "^3.3.2",
"@docusaurus/tsconfig": "^3.7.0", "@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.3.2", "@docusaurus/types": "^3.3.2",
"@types/react": "^18.3.13", "@types/react": "^19.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"prettier": "3.5.3", "prettier": "3.5.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",