Compare commits

..

15 Commits

Author SHA1 Message Date
00c1e17b52 Merge branch 'main' into web/add-command-palette
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	web/package-lock.json
#	web/package.json
#	web/src/admin/AdminInterface/AdminInterface.ts
2025-06-14 02:17:24 +02:00
3c2ce40afd web/admin: Text and Textarea Fields that "hide" their contents until prompted (#15024)
* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web/admin: Provide `hidden` text and textarea components

## Details

This commit provides two new elements (technically, since they're API-unaware), one for `<input
type="text">`, and one for `<textarea>`, that provide for the ability to create fields that are (or
can be) hidden. A new boolean attribute, `revealed`, shows the state of the component (the content
is therefore *not* revealed by default).

It also includes a third new element, `ak-visibility-toggle`, that creates a hide/show toggle with
all the right icons, styling, and eventing.  It's straightforward, and isolating it improved the
DX of everything that uses that feature by quite a bit.

Storybook stories (with autodoc documentation) have been provided for `ak-hidden-text-input`,
`ak-hidden-textarea-input`, and `ak-visibility-toggle`.

## Maintenance Notice

As a maintenance detail, the field `ak-private-text` has been renamed `ak-secret-text` to reflect
its usage, and the places where it was used have all been changed to reflect that update.

* web/component: embed styling (for now) to handle the lightDom/shadowDom/slot conflicts in HorizontalLightComponent and HorizontalFormElement

* Comments and Types. I really shouldn't have to catch this stuff with my eyeballs.

* fix typo

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-14 01:48:34 +02:00
2aceed285e providers/rac: fixes prompt data not being merged with connection_settings (#15037)
* Fixes line that pulls in prompt data

* fallback to old settings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-13 18:54:20 +02:00
cba8e84bbe fix accent color
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-17 03:18:50 +01:00
d313fd7fb4 set dark theme
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-17 03:10:02 +01:00
102811508f fix z-index
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-03-17 03:09:53 +01:00
16b3ca3715 web: add command palette
Adds [Ninja Keys](https://github.com/ssleptsov/ninja-keys) (MIT License) to the Admin Interface.
This is a trivial demo. Start the UI and begin by pressing CMD-K or CTRL-K in the Admin Interface.

- Because a lot of the Create and Update operations are accessible as activations-on-modals rather
  than navigable components in their own right, we don't have a way to encode commands like "Start
  the Application Wizard" or "Add a new Policy."
- Our search isn't very quick, so "Edit Application Foo" would have intolerable delays while we
  looked up a list of applications that could be edited.
- The `icon` feature is relatively difficult to exploit because it's a web component and its inner
  styles are inacessible.
- There seem to be cases where the modal-popup and charts conflict.

If we want to move forward with this, we have the challenge of finding activation means for the
various Create and Edit features, and whether or not Search can be meaningfully integrated into the
results.

It would also be nifty if the User and Admin palettes treated each other as peers; you could just go
"CMD-K My User Page" and it would just *take* you there, and "CMD-K Admin Applications" to go back,
creating an illusion of seamlessness.
2024-03-15 15:21:51 -07:00
8b4e0361c4 Merge branch 'main' into dev
* main:
  web: clean up and remove redundant alias '@goauthentik/app' (#8889)
  web/admin: fix markdown table rendering (#8908)
2024-03-14 10:35:46 -07:00
22cb5b7379 Merge branch 'main' into dev
* main:
  web: bump chromedriver from 122.0.5 to 122.0.6 in /tests/wdio (#8902)
  web: bump vite-tsconfig-paths from 4.3.1 to 4.3.2 in /web (#8903)
  core: bump google.golang.org/protobuf from 1.32.0 to 1.33.0 (#8901)
  web: provide InstallID on EnterpriseListPage (#8898)
2024-03-14 08:14:43 -07:00
2d0117d096 Merge branch 'main' into dev
* main:
  api: capabilities: properly set can_save_media when s3 is enabled (#8896)
  web: bump the rollup group in /web with 3 updates (#8891)
  core: bump pydantic from 2.6.3 to 2.6.4 (#8892)
  core: bump twilio from 9.0.0 to 9.0.1 (#8893)
2024-03-13 14:05:11 -07:00
035bda4eac Merge branch 'main' into dev
* main:
  Update _envoy_istio.md (#8888)
  website/docs: new landing page for Providers (#8879)
  web: bump the sentry group in /web with 1 update (#8881)
  web: bump chromedriver from 122.0.4 to 122.0.5 in /tests/wdio (#8884)
  web: bump the eslint group in /tests/wdio with 2 updates (#8883)
  web: bump the eslint group in /web with 2 updates (#8885)
  website: bump @types/react from 18.2.64 to 18.2.65 in /website (#8886)
2024-03-12 13:31:35 -07:00
50906214e5 Merge branch 'main' into dev
* main:
  web: upgrade to lit 3 (#8781)
2024-03-11 11:03:04 -07:00
e505f274b6 Merge branch 'main' into dev
* main:
  web: fix esbuild issue with style sheets (#8856)
2024-03-11 10:28:05 -07:00
fe52f44dca Merge branch 'main' into dev
* main:
  tenants: really ensure default tenant cannot be deleted (#8875)
  core: bump github.com/go-openapi/runtime from 0.27.2 to 0.28.0 (#8867)
  core: bump pytest from 8.0.2 to 8.1.1 (#8868)
  core: bump github.com/go-openapi/strfmt from 0.22.2 to 0.23.0 (#8869)
  core: bump bandit from 1.7.7 to 1.7.8 (#8870)
  core: bump packaging from 23.2 to 24.0 (#8871)
  core: bump ruff from 0.3.1 to 0.3.2 (#8873)
  web: bump the wdio group in /tests/wdio with 3 updates (#8865)
  core: bump requests-oauthlib from 1.3.1 to 1.4.0 (#8866)
  core: bump uvicorn from 0.27.1 to 0.28.0 (#8872)
  core: bump django-filter from 23.5 to 24.1 (#8874)
2024-03-11 10:27:43 -07:00
3146e5a50f web: fix esbuild issue with style sheets
Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious
pain. This fix better identifies the value types (instances) being passed from various sources in
the repo to the three *different* kinds of style processors we're using (the native one, the
polyfill one, and whatever the heck Storybook does internally).

Falling back to using older CSS instantiating techniques one era at a time seems to do the trick.
It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content
(FLoUC), it's the logic with which we're left.

In standard mode, the following warning appears on the console when running a Flow:

```
Autofocus processing was blocked because a document already has a focused element.
```

In compatibility mode, the following **error** appears on the console when running a Flow:

```
crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'.
    at initDomMutationObservers (crawler-inject.js:1106:18)
    at crawler-inject.js:1114:24
    at Array.forEach (<anonymous>)
    at initDomMutationObservers (crawler-inject.js:1114:10)
    at crawler-inject.js:1549:1
initDomMutationObservers @ crawler-inject.js:1106
(anonymous) @ crawler-inject.js:1114
initDomMutationObservers @ crawler-inject.js:1114
(anonymous) @ crawler-inject.js:1549
```

Despite this error, nothing seems to be broken and flows work as anticipated.
2024-03-08 14:15:55 -08:00
37 changed files with 1169 additions and 609 deletions

View File

@ -69,7 +69,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query"
@ -455,7 +454,6 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN,
SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests
# SESSION_KEY_POST,
# We don't delete the history on purpose, as a user might

View File

@ -6,8 +6,7 @@ from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
from authentik.flows.models import Flow
class FlowInterfaceView(InterfaceView):
@ -15,12 +14,6 @@ class FlowInterfaceView(InterfaceView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
if (
not self.request.user.is_authenticated
and flow.designation == FlowDesignation.AUTHENTICATION
):
self.request.session[SESSION_KEY_AUTH_STARTED] = True
self.request.session.save()
kwargs["flow"] = flow
kwargs["flow_background_url"] = flow.background_url(self.request)
kwargs["inspector"] = "inspector" in self.request.GET

View File

@ -39,4 +39,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
default = True
mountpoint = "policy/"

View File

@ -1,89 +0,0 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<script>
let redirecting = false;
const checkAuth = async () => {
if (redirecting) return true;
const url = "{{ check_auth_url }}";
console.debug("authentik/policies/buffer: Checking authentication...");
try {
const result = await fetch(url, {
method: "HEAD",
});
if (result.status >= 400) {
return false
}
console.debug("authentik/policies/buffer: Continuing");
redirecting = true;
if ("{{ auth_req_method }}" === "post") {
document.querySelector("form").submit();
} else {
window.location.assign("{{ continue_url|escapejs }}");
}
} catch {
return false;
}
};
let timeout = 100;
let offset = 20;
let attempt = 0;
const main = async () => {
attempt += 1;
await checkAuth();
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
setTimeout(main, timeout);
timeout += (offset * attempt);
if (timeout >= 2000) {
timeout = 2000;
}
}
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
await checkAuth();
});
main();
</script>
{% endblock %}
{% block title %}
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% trans 'Waiting for authentication...' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
{% if auth_req_method == "post" %}
{% for key, value in auth_req_body.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% endif %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<div class="pf-c-empty-state__icon">
<span class="pf-c-spinner pf-m-xl" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h1 class="pf-c-title pf-m-lg">
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
</h1>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
{% trans "Authenticate in this tab" %}
</a>
</div>
</form>
{% endblock %}

View File

@ -1,121 +0,0 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.models import Application, Provider
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.policies.views import (
QS_BUFFER_ID,
SESSION_KEY_BUFFER,
BufferedPolicyAccessView,
BufferView,
PolicyAccessView,
)
class TestPolicyViews(TestCase):
"""Test PolicyAccessView"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_user()
def test_pav(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
class TestView(PolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = self.user
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, b"foo")
def test_pav_buffer(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
def test_pav_buffer_skip(self):
"""Test simple policy access view (skip buffer)"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/?skip_buffer=true")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
def test_buffer(self):
"""Test buffer view"""
uid = generate_id()
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
ts = generate_id()
req.session[SESSION_KEY_BUFFER % uid] = {
"method": "get",
"body": {},
"url": f"/{ts}",
}
req.session.save()
res = BufferView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertIn(ts, res.render().content.decode())

View File

@ -1,14 +1,7 @@
"""API URLs"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [
("policies/all", PolicyViewSet),

View File

@ -1,37 +1,23 @@
"""authentik access helper classes"""
from typing import Any
from uuid import uuid4
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from django.views.generic.base import TemplateView, View
from django.views.generic.base import View
from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_AUTH_STARTED,
SESSION_KEY_PLAN,
SESSION_KEY_POST,
)
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
QS_BUFFER_ID = "af_bf_id"
QS_SKIP_BUFFER = "skip_buffer"
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
class RequestValidationError(SentryIgnoredException):
@ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages:
messages.error(self.request, _(message))
return result
def url_with_qs(url: str, **kwargs):
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
parameters are retained"""
if "?" not in url:
return url + f"?{urlencode(kwargs)}"
url, _, qs = url.partition("?")
qs = QueryDict(qs, mutable=True)
qs.update(kwargs)
return url + f"?{urlencode(qs.items())}"
class BufferView(TemplateView):
"""Buffer view"""
template_name = "policies/buffer.html"
def get_context_data(self, **kwargs):
buf_id = self.request.GET.get(QS_BUFFER_ID)
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
kwargs["auth_req_method"] = buffer["method"]
kwargs["auth_req_body"] = buffer["body"]
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
return super().get_context_data(**kwargs)
class BufferedPolicyAccessView(PolicyAccessView):
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
def handle_no_permission(self):
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
if plan:
flow = Flow.objects.filter(pk=plan.flow_pk).first()
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
return super().handle_no_permission()
if not plan and authenticating is None:
LOGGER.debug("Not buffering request, no flow plan active")
return super().handle_no_permission()
if self.request.GET.get(QS_SKIP_BUFFER):
LOGGER.debug("Not buffering request, explicit skip")
return super().handle_no_permission()
buffer_id = str(uuid4())
LOGGER.debug("Buffering access request", bf_id=buffer_id)
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
"body": self.request.POST,
"url": self.request.build_absolute_uri(self.request.get_full_path()),
"method": self.request.method.lower(),
}
return redirect(
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if QS_BUFFER_ID in self.request.GET:
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
return response

View File

@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN,
PKCE_METHOD_S256,
@ -326,7 +326,7 @@ class OAuthAuthorizationParams:
return code
class AuthorizationFlowInitView(BufferedPolicyAccessView):
class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams

View File

@ -18,11 +18,14 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.views import BufferedPolicyAccessView
from authentik.policies.views import PolicyAccessView
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
PLAN_CONNECTION_SETTINGS = "connection_settings"
class RACStartView(BufferedPolicyAccessView):
class RACStartView(PolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint
@ -109,10 +112,15 @@ class RACFinalStage(RedirectStage):
return super().dispatch(request, *args, **kwargs)
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
settings = self.executor.plan.context.get(PLAN_CONNECTION_SETTINGS)
if not settings:
settings = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).get(
PLAN_CONNECTION_SETTINGS
)
token = ConnectionToken.objects.create(
provider=self.provider,
endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}),
settings=settings or {},
session=self.request.session["authenticatedsession"],
expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True,

View File

@ -35,8 +35,8 @@ REQUEST_KEY_SAML_SIG_ALG = "SigAlg"
REQUEST_KEY_SAML_RESPONSE = "SAMLResponse"
REQUEST_KEY_RELAY_STATE = "RelayState"
PLAN_CONTEXT_SAML_AUTH_N_REQUEST = "authentik/providers/saml/authn_request"
PLAN_CONTEXT_SAML_LOGOUT_REQUEST = "authentik/providers/saml/logout_request"
SESSION_KEY_AUTH_N_REQUEST = "authentik/providers/saml/authn_request"
SESSION_KEY_LOGOUT_REQUEST = "authentik/providers/saml/logout_request"
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
@ -50,11 +50,10 @@ class SAMLFlowFinalView(ChallengeStageView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
provider: SAMLProvider = get_object_or_404(SAMLProvider, pk=application.provider_id)
if PLAN_CONTEXT_SAML_AUTH_N_REQUEST not in self.executor.plan.context:
self.logger.warning("No AuthNRequest in context")
if SESSION_KEY_AUTH_N_REQUEST not in self.request.session:
return self.executor.stage_invalid()
auth_n_request: AuthNRequest = self.executor.plan.context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST]
auth_n_request: AuthNRequest = self.request.session.pop(SESSION_KEY_AUTH_N_REQUEST)
try:
response = AssertionProcessor(provider, request, auth_n_request).build_response()
except SAMLException as exc:
@ -107,3 +106,6 @@ class SAMLFlowFinalView(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# We'll never get here since the challenge redirects to the SP
return HttpResponseBadRequest()
def cleanup(self):
self.request.session.pop(SESSION_KEY_AUTH_N_REQUEST, None)

View File

@ -19,9 +19,9 @@ from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
from authentik.providers.saml.views.flows import (
PLAN_CONTEXT_SAML_LOGOUT_REQUEST,
REQUEST_KEY_RELAY_STATE,
REQUEST_KEY_SAML_REQUEST,
SESSION_KEY_LOGOUT_REQUEST,
)
LOGGER = get_logger()
@ -33,10 +33,6 @@ class SAMLSLOView(PolicyAccessView):
flow: Flow
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.plan_context = {}
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider: SAMLProvider = get_object_or_404(
@ -63,7 +59,6 @@ class SAMLSLOView(PolicyAccessView):
request,
{
PLAN_CONTEXT_APPLICATION: self.application,
**self.plan_context,
},
)
plan.append_stage(in_memory_stage(SessionEndStage))
@ -88,7 +83,7 @@ class SAMLSLOBindingRedirectView(SAMLSLOView):
self.request.GET[REQUEST_KEY_SAML_REQUEST],
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
)
self.plan_context[PLAN_CONTEXT_SAML_LOGOUT_REQUEST] = logout_request
self.request.session[SESSION_KEY_LOGOUT_REQUEST] = logout_request
except CannotHandleAssertion as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
@ -116,7 +111,7 @@ class SAMLSLOBindingPOSTView(SAMLSLOView):
payload[REQUEST_KEY_SAML_REQUEST],
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
)
self.plan_context[PLAN_CONTEXT_SAML_LOGOUT_REQUEST] = logout_request
self.request.session[SESSION_KEY_LOGOUT_REQUEST] = logout_request
except CannotHandleAssertion as exc:
LOGGER.info(str(exc))
return bad_request_message(self.request, str(exc))

View File

@ -15,16 +15,16 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.policies.views import BufferedPolicyAccessView
from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
from authentik.providers.saml.views.flows import (
PLAN_CONTEXT_SAML_AUTH_N_REQUEST,
REQUEST_KEY_RELAY_STATE,
REQUEST_KEY_SAML_REQUEST,
REQUEST_KEY_SAML_SIG_ALG,
REQUEST_KEY_SAML_SIGNATURE,
SESSION_KEY_AUTH_N_REQUEST,
SAMLFlowFinalView,
)
from authentik.stages.consent.stage import (
@ -35,14 +35,10 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger()
class SAMLSSOView(BufferedPolicyAccessView):
class SAMLSSOView(PolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.plan_context = {}
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider: SAMLProvider = get_object_or_404(
@ -72,7 +68,6 @@ class SAMLSSOView(BufferedPolicyAccessView):
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
% {"application": self.application.name},
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
**self.plan_context,
},
)
except FlowNonApplicableException:
@ -88,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
override .dispatch easily because PolicyAccessView's dispatch"""
return self.get(request, application_slug)
@ -108,7 +103,7 @@ class SAMLSSOBindingRedirectView(SAMLSSOView):
self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
)
self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
@ -142,7 +137,7 @@ class SAMLSSOBindingPOSTView(SAMLSSOView):
payload[REQUEST_KEY_SAML_REQUEST],
payload.get(REQUEST_KEY_RELAY_STATE),
)
self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc:
LOGGER.info(str(exc))
return bad_request_message(self.request, str(exc))
@ -156,4 +151,4 @@ class SAMLSSOBindingInitView(SAMLSSOView):
"""Create SAML Response from scratch"""
LOGGER.debug("No SAML Request, using IdP-initiated flow.")
auth_n_request = AuthNRequestParser(self.provider).idp_initiated()
self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request

View File

@ -410,77 +410,3 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied",
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
@apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied_parallel(self):
"""test OpenID Provider flow (default authorization flow with implied consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id,
client_secret=self.client_secret,
signing_key=create_test_cert(),
redirect_uris=[
RedirectURI(
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
)
],
authorization_flow=authorization_flow,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
)
)
Application.objects.create(
name=generate_id(),
slug=self.app_slug,
provider=provider,
)
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
grafana_window = self.driver.current_window_handle
self.driver.get("http://localhost:3000")
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(grafana_window)
self.wait_for_url("http://localhost:3000/?orgId=1")
self.driver.get("http://localhost:3000/profile")
self.assertEqual(
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute("value"),
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=email]").get_attribute("value"),
self.user.email,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=login]").get_attribute("value"),
self.user.email,
)

View File

@ -20,7 +20,7 @@ from tests.e2e.utils import SeleniumTestCase, retry
class TestProviderSAML(SeleniumTestCase):
"""test SAML Provider flow"""
def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs):
def setup_client(self, provider: SAMLProvider, force_post: bool = False):
"""Setup client saml-sp container which we test SAML against"""
metadata_url = (
self.url(
@ -40,7 +40,6 @@ class TestProviderSAML(SeleniumTestCase):
"SP_ENTITY_ID": provider.issuer,
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"SP_METADATA_URL": metadata_url,
**kwargs,
},
)
@ -112,74 +111,6 @@ class TestProviderSAML(SeleniumTestCase):
[self.user.email],
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url="http://localhost:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True)
self.driver.get("http://localhost:9009")
self.login()
self.wait_for_url("http://localhost:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -519,81 +450,3 @@ class TestProviderSAML(SeleniumTestCase):
lambda driver: driver.current_url.startswith(should_url),
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post_buffer(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url=f"http://{self.host}:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True, SP_ROOT_URL=f"http://{self.host}:9009")
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
client_window = self.driver.current_window_handle
# We need to access the SP on the same host as the IdP for SameSite cookies
self.driver.get(f"http://{self.host}:9009")
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(client_window)
self.wait_for_url(f"http://{self.host}:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)

109
web/package-lock.json generated
View File

@ -51,6 +51,7 @@
"lit": "^3.2.0",
"md-front-matter": "^1.0.4",
"mermaid": "^11.6.0",
"ninja-keys": "^1.2.2",
"rapidoc": "^9.3.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -2587,6 +2588,57 @@
"@lit/reactive-element": "^1.0.0 || ^2.0.0"
}
},
"node_modules/@material/mwc-icon": {
"version": "0.25.3",
"resolved": "https://registry.npmjs.org/@material/mwc-icon/-/mwc-icon-0.25.3.tgz",
"integrity": "sha512-36076AWZIRSr8qYOLjuDDkxej/HA0XAosrj7TS1ZeLlUBnLUtbDtvc1S7KSa0hqez7ouzOqGaWK24yoNnTa2OA==",
"deprecated": "MWC beta is longer supported. Please upgrade to @material/web",
"license": "Apache-2.0",
"dependencies": {
"lit": "^2.0.0",
"tslib": "^2.0.1"
}
},
"node_modules/@material/mwc-icon/node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@material/mwc-icon/node_modules/lit": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@material/mwc-icon/node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@material/mwc-icon/node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz",
@ -16940,6 +16992,12 @@
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
"node_modules/hotkeys-js": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.8.7.tgz",
"integrity": "sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==",
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -21632,6 +21690,57 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node_modules/ninja-keys": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/ninja-keys/-/ninja-keys-1.2.2.tgz",
"integrity": "sha512-ylo8jzKowi3XBHkgHRjBJaKQkl32WRLr7kRiA0ajiku11vHRDJ2xANtTScR5C7XlDwKEOYvUPesCKacUeeLAYw==",
"license": "MIT",
"dependencies": {
"@material/mwc-icon": "0.25.3",
"hotkeys-js": "3.8.7",
"lit": "2.2.6"
}
},
"node_modules/ninja-keys/node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/ninja-keys/node_modules/lit": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.2.6.tgz",
"integrity": "sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.3.0",
"lit-element": "^3.2.0",
"lit-html": "^2.2.0"
}
},
"node_modules/ninja-keys/node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/ninja-keys/node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",

View File

@ -122,6 +122,7 @@
"lit": "^3.2.0",
"md-front-matter": "^1.0.4",
"mermaid": "^11.6.0",
"ninja-keys": "^1.2.2",
"rapidoc": "^9.3.8",
"react": "^19.1.0",
"react-dom": "^19.1.0",

View File

@ -0,0 +1,172 @@
import { navigate } from "@goauthentik/elements/router/RouterOutlet";
import { INinjaAction } from "ninja-keys/dist/interfaces/ininja-action.js";
import { msg } from "@lit/localize";
export const adminCommands: INinjaAction[] = [
{
id: msg("Overview"),
title: msg("Dashboard"),
handler: () => navigate("/administration/overview"),
section: msg("Dashboards"),
},
{
handler: () => navigate("/administration/dashboard/users"),
id: msg("User Statistics"),
title: msg("User Statistics"),
icon: '<i class="pf-icon pf-icon-user"></i>',
section: msg("Dashboards"),
},
{
handler: () => navigate("/administration/system-tasks"),
id: msg("System Tasks"),
title: msg("System Tasks"),
section: msg("Dashboards"),
},
{
handler: () => navigate("/core/applications"),
id: msg("Applications"),
title: msg("Applications"),
section: msg("Applications"),
},
{
handler: () => navigate("/core/providers"),
id: msg("Providers"),
title: msg("Providers"),
section: msg("Applications"),
},
{
handler: () => navigate("/outpost/outposts"),
id: msg("Outposts"),
title: msg("Outposts"),
section: msg("Applications"),
},
{
handler: () => navigate("/events/log"),
id: msg("Logs"),
title: msg("Logs"),
section: msg("Events"),
},
{
handler: () => navigate("/events/rules"),
id: msg("Notification Rules"),
title: msg("Notification Rules"),
section: msg("Events"),
},
{
handler: () => navigate("/events/transports"),
id: msg("Notification Transports"),
title: msg("Notification Transports"),
section: msg("Events"),
},
{
handler: () => navigate("/policy/policies"),
id: msg("Policies"),
title: msg("Policies"),
section: msg("Customization"),
},
{
handler: () => navigate("/core/property-mappings"),
id: msg("Property Mappings"),
title: msg("Property Mappings"),
section: msg("Customization"),
},
{
handler: () => navigate("/blueprints/instances"),
id: msg("Blueprints"),
title: msg("Blueprints"),
section: msg("Customization"),
},
{
handler: () => navigate("/policy/reputation"),
id: msg("Reputation scores"),
title: msg("Reputation scores"),
section: msg("Customization"),
},
{
handler: () => navigate("/flow/flows"),
id: msg("Flows"),
title: msg("Flows"),
section: msg("Flows"),
},
{
handler: () => navigate("/flow/stages"),
id: msg("Stages"),
title: msg("Stages"),
section: msg("Flows"),
},
{
handler: () => navigate("/flow/stages/prompts"),
id: msg("Prompts"),
title: msg("Prompts"),
section: msg("Flows"),
},
{
handler: () => navigate("/identity/users"),
id: msg("Users"),
title: msg("Users"),
section: msg("Directory"),
},
{
handler: () => navigate("/identity/groups"),
id: msg("Groups"),
title: msg("Groups"),
section: msg("Directory"),
},
{
handler: () => navigate("/identity/roles"),
id: msg("Roles"),
title: msg("Roles"),
section: msg("Directory"),
},
{
handler: () => navigate("/core/sources"),
id: msg("Federation and Social login"),
title: msg("Federation and Social login"),
section: msg("Directory"),
},
{
handler: () => navigate("/core/tokens"),
id: msg("Tokens and App passwords"),
title: msg("Tokens and App passwords"),
section: msg("Directory"),
},
{
handler: () => navigate("/flow/stages/invitations"),
id: msg("Invitations"),
title: msg("Invitations"),
section: msg("Directory"),
},
{
handler: () => navigate("/core/brands"),
id: msg("Brands"),
title: msg("Brands"),
section: msg("System"),
},
{
handler: () => navigate("/crypto/certificates"),
id: msg("Certificates"),
title: msg("Certificates"),
section: msg("System"),
},
{
handler: () => navigate("/outpost/integrations"),
id: msg("Outpost Integrations"),
title: msg("Outpost Integrations"),
section: msg("System"),
},
{
handler: () => navigate("/admin/settings"),
id: msg("Settings"),
title: msg("Settings"),
section: msg("System"),
},
{
handler: () => window.location.assign("/if/user/"),
id: msg("User interface"),
title: msg("Go to my User page"),
},
];

View File

@ -1,5 +1,6 @@
import "#admin/AdminInterface/AboutModal";
import type { AboutModal } from "#admin/AdminInterface/AboutModal";
import { adminCommands } from "#admin/AdminInterface/AdminCommands";
import { ROUTES } from "#admin/Routes";
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
import { configureSentry } from "#common/sentry/index";
@ -21,6 +22,7 @@ import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import "#elements/router/RouterOutlet";
import "#elements/sidebar/Sidebar";
import "#elements/sidebar/SidebarItem";
import "ninja-keys";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, eventOptions, property, query } from "lit/decorators.js";
@ -119,6 +121,10 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
.pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl);
}
ninja-keys {
--ninja-z-index: 99999;
--ninja-accent-color: var(--ak-accent);
}
`,
];
@ -190,6 +196,11 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
};
return html` <ak-locale-context>
<ninja-keys
.data=${adminCommands}
noAutoLoadMdicons
class="${this.activeTheme === UiThemeEnum.Dark ? "dark" : ""}"
></ninja-keys>
<div class="pf-c-page">
<ak-page-navbar ?open=${this.sidebarOpen} @sidebar-toggle=${this.sidebarListener}>
<ak-version-banner></ak-version-banner>

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-textarea-input.js";
import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -46,7 +46,7 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
required
/>
</ak-form-element-horizontal>
<ak-private-textarea-input
<ak-secret-textarea-input
label=${msg("Certificate")}
name="certificateData"
input-hint="code"
@ -54,8 +54,8 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
required
?revealed=${this.instance === undefined}
help=${msg("PEM-encoded Certificate data.")}
></ak-private-textarea-input>
<ak-private-textarea-input
></ak-secret-textarea-input>
<ak-secret-textarea-input
label=${msg("Private Key")}
name="keyData"
input-hint="code"
@ -63,7 +63,7 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
help=${msg(
"Optional Private Key. If this is set, you can use this keypair for encryption.",
)}
></ak-private-textarea-input>`;
></ak-secret-textarea-input>`;
}
}

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/common/constants";
import "@goauthentik/components/ak-private-textarea-input.js";
import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -62,13 +62,13 @@ export class EnterpriseLicenseForm extends ModelForm<License, string> {
value="${ifDefined(this.installID)}"
/>
</ak-form-element-horizontal>
<ak-private-textarea-input
<ak-secret-textarea-input
name="key"
?revealed=${this.instance === undefined}
label=${msg("License key")}
input-hint="code"
>
</ak-private-textarea-input>`;
</ak-secret-textarea-input>`;
}
}

View File

@ -7,8 +7,8 @@ import {
UserMatchingModeToLabel,
} from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-private-textarea-input.js";
import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-input";
@ -248,22 +248,22 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm<Ke
value=${ifDefined(this.instance?.syncPrincipal)}
help=${msg("Principal used to authenticate to the KDC for syncing.")}
></ak-text-input>
<ak-private-text-input
<ak-secret-text-input
name="syncPassword"
label=${msg("Sync password")}
?revealed=${this.instance === undefined}
help=${msg(
"Password used to authenticate to the KDC for syncing. Optional if Sync keytab or Sync credentials cache is provided.",
)}
></ak-private-text-input>
<ak-private-textarea-input
></ak-secret-text-input>
<ak-secret-textarea-input
name="syncKeytab"
label=${msg("Sync keytab")}
?revealed=${this.instance === undefined}
help=${msg(
"Keytab used to authenticate to the KDC for syncing. Optional if Sync password or Sync credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.",
)}
></ak-private-textarea-input>
></ak-secret-textarea-input>
<ak-text-input
name="syncCcache"
label=${msg("Sync credentials cache")}
@ -285,14 +285,14 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm<Ke
"Force the use of a specific server name for SPNEGO. Must be in the form HTTP@domain",
)}
></ak-text-input>
<ak-private-textarea-input
<ak-secret-textarea-input
name="spnegoKeytab"
label=${msg("SPNEGO keytab")}
?revealed=${this.instance === undefined}
help=${msg(
"Keytab used for SPNEGO. Optional if SPNEGO credentials cache is provided. Must be base64 encoded or in the form TYPE:residual.",
)}
></ak-private-textarea-input>
></ak-secret-textarea-input>
<ak-text-input
name="spnegoCcache"
label=${msg("SPNEGO credentials cache")}

View File

@ -2,7 +2,7 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search";
import { placeholderHelperText } from "@goauthentik/admin/helperText";
import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
@ -260,11 +260,11 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-private-text-input
<ak-secret-text-input
label=${msg("Bind Password")}
name="bindPassword"
?revealed=${this.instance === undefined}
></ak-private-text-input>
></ak-secret-text-input>
<ak-form-element-horizontal label=${msg("Base DN")} required name="baseDn">
<input
type="text"

View File

@ -8,8 +8,8 @@ import {
UserMatchingModeToLabel,
} from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-textarea-input.js";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
@ -441,14 +441,14 @@ export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm<OAuth
/>
<p class="pf-c-form__helper-text">${msg("Also known as Client ID.")}</p>
</ak-form-element-horizontal>
<ak-private-textarea-input
<ak-secret-textarea-input
label=${msg("Consumer secret")}
name="consumerSecret"
input-hint="code"
help=${msg("Also known as Client Secret.")}
required
?revealed=${this.instance === undefined}
></ak-private-textarea-input>
></ak-secret-textarea-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="additionalScopes">
<input
type="text"

View File

@ -1,7 +1,7 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";
@ -95,13 +95,13 @@ export class AuthenticatorDuoStageForm extends BaseStageForm<AuthenticatorDuoSta
required
/>
</ak-form-element-horizontal>
<ak-private-text-input
<ak-secret-text-input
name="clientSecret"
label=${msg("Secret key")}
input-hint="code"
required
?revealed=${this.instance === undefined}
></ak-private-text-input>
></ak-secret-text-input>
</div>
</ak-form-group>
<ak-form-group>
@ -125,12 +125,12 @@ export class AuthenticatorDuoStageForm extends BaseStageForm<AuthenticatorDuoSta
spellcheck="false"
/>
</ak-form-element-horizontal>
<ak-private-text-input
<ak-secret-text-input
name="adminSecretKey"
label=${msg("Secret key")}
input-hint="code"
?revealed=${this.instance === undefined}
></ak-private-text-input>
></ak-secret-text-input>
</div>
</ak-form-group>
<ak-form-group expanded>

View File

@ -1,7 +1,7 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/Radio";
@ -77,11 +77,11 @@ export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmai
/>
</ak-form-element-horizontal>
<ak-private-text-input
<ak-secret-text-input
name="password"
label=${msg("SMTP Password")}
?revealed=${this.instance === undefined}
></ak-private-text-input>
></ak-secret-text-input>
<ak-form-element-horizontal name="useTls">
<label class="pf-c-switch">

View File

@ -1,7 +1,7 @@
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
@ -70,7 +70,7 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
</p>
</ak-form-element-horizontal>
<ak-private-text-input
<ak-secret-text-input
name="privateKey"
label=${msg("Private Key")}
input-hint="code"
@ -79,7 +79,7 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
help=${msg(
"Private key, acquired from https://www.google.com/recaptcha/intro/v3.html.",
)}
></ak-private-text-input>
></ak-secret-text-input>
<ak-switch-input
name="interactive"

View File

@ -1,6 +1,6 @@
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-secret-text-input.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/utils/TimeDeltaHelp";
@ -73,11 +73,11 @@ export class EmailStageForm extends BaseStageForm<EmailStage> {
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-private-text-input
<ak-secret-text-input
label=${msg("SMTP Password")}
name="password"
?revealed=${this.instance === undefined}
></ak-private-text-input>
></ak-secret-text-input>
<ak-form-element-horizontal name="useTls">
<label class="pf-c-switch">
<input

View File

@ -1,4 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { AKElement, type AKElementProps } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/HorizontalFormElement.js";
import { TemplateResult, html, nothing } from "lit";
@ -6,6 +6,19 @@ import { property } from "lit/decorators.js";
type HelpType = TemplateResult | typeof nothing;
export interface HorizontalLightComponentProps<T> extends AKElementProps {
name: string;
label?: string;
required?: boolean;
help?: string;
bighelp?: TemplateResult | TemplateResult[];
hidden?: boolean;
invalid?: boolean;
errorMessages?: string[];
value?: T;
inputHint?: string;
}
export class HorizontalLightComponent<T> extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
@ -18,37 +31,81 @@ export class HorizontalLightComponent<T> extends AKElement {
return this;
}
/**
* The name attribute for the form element
* @property
* @attribute
*/
@property({ type: String, reflect: true })
name!: string;
/**
* The label for the input control
* @property
* @attribute
*/
@property({ type: String, reflect: true })
label = "";
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
required = false;
/**
* Help text to display below the form element. Optional
* @property
* @attribute
*/
@property({ type: String, reflect: true })
help = "";
/**
* Extended help content. Optional. Expects to be a TemplateResult
* @property
*/
@property({ type: Object })
bighelp?: TemplateResult | TemplateResult[];
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
hidden = false;
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
invalid = false;
/**
* @property
*/
@property({ attribute: false })
errorMessages: string[] = [];
/**
* @attribute
* @property
*/
@property({ attribute: false })
value?: T;
/**
* Input hint.
* - `code`: uses a monospace font and disables spellcheck & autocomplete
* @property
* @attribute
*/
@property({ type: String, attribute: "input-hint" })
inputHint = "";
renderControl() {
protected renderControl() {
throw new Error("Must be implemented in a subclass");
}

View File

@ -0,0 +1,159 @@
import { bound } from "#elements/decorators/bound";
import { msg } from "@lit/localize";
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
HorizontalLightComponent,
HorizontalLightComponentProps,
} from "./HorizontalLightComponent";
import "./ak-visibility-toggle.js";
import type { VisibilityToggleProps } from "./ak-visibility-toggle.js";
type BaseProps = HorizontalLightComponentProps<string> &
Pick<VisibilityToggleProps, "showMessage" | "hideMessage">;
export interface AkHiddenTextInputProps extends BaseProps {
revealed: boolean;
placeholder?: string;
}
export type InputLike = HTMLTextAreaElement | HTMLInputElement;
/**
* @element ak-hidden-text-input
* @class AkHiddenTextInput
*
* A text-input field with a visibility control, so you can show/hide sensitive fields.
*
* ## CSS Parts
* @csspart container - The main container div
* @csspart input - The input element
* @csspart toggle - The visibility toggle button
*
*/
@customElement("ak-hidden-text-input")
export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
extends HorizontalLightComponent<string>
implements AkHiddenTextInputProps
{
public static get styles() {
return [
css`
main {
display: flex;
}
`,
];
}
/**
* @property
* @attribute
*/
@property({ type: String, reflect: true })
public value = "";
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
public revealed = false;
/**
* Text for when the input has no set value
*
* @property
* @attribute
*/
@property({ type: String })
public placeholder?: string;
/**
* Specify kind of help the browser should try to provide
*
* @property
* @attribute
*/
@property({ type: String })
public autocomplete?: "none" | AutoFill;
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "show-message" })
public showMessage = msg("Show field content");
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "hide-message" })
public hideMessage = msg("Hide field content");
@query("#main > input")
protected inputField!: T;
@bound
private handleToggleVisibility() {
this.revealed = !this.revealed;
// Maintain focus on input after toggle
this.updateComplete.then(() => {
if (this.inputField && document.activeElement === this) {
this.inputField.focus();
}
});
}
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
// in the LightDom so the inner components actually inherit styling, the normal `css` options
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
// refresh.
protected renderInputField(setValue: (ev: InputEvent) => void, code: boolean) {
return html` <input
style="flex: 1 1 auto; min-width: 0;"
part="input"
type=${this.revealed ? "text" : "password"}
@input=${setValue}
value=${ifDefined(this.value)}
placeholder=${ifDefined(this.placeholder)}
class="${classMap({
"pf-c-form-control": true,
"pf-m-monospace": code,
})}"
spellcheck=${code ? "false" : "true"}
?required=${this.required}
/>`;
}
protected override renderControl() {
const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => {
this.value = (ev.target as T).value;
};
return html` <div style="display: flex; gap: 0.25rem">
${this.renderInputField(setValue, code)}
<!-- -->
<ak-visibility-toggle
part="toggle"
style="flex: 0 0 auto; align-self: flex-start"
?open=${this.revealed}
show-message=${this.showMessage}
hide-message=${this.hideMessage}
@click=${() => (this.revealed = !this.revealed)}
></ak-visibility-toggle>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-hidden-text-input": AkHiddenTextInput;
}
}

View File

@ -0,0 +1,128 @@
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { AkHiddenTextInput, type AkHiddenTextInputProps } from "./ak-hidden-text-input.js";
export interface AkHiddenTextAreaInputProps extends AkHiddenTextInputProps {
/**
* Number of visible text lines (rows)
*/
rows?: number;
/**
* Number of visible character width (cols)
*/
cols?: number;
/**
* How the textarea can be resized
*/
resize?: "none" | "both" | "horizontal" | "vertical";
/**
* Whether text should wrap
*/
wrap?: "soft" | "hard" | "off";
}
/**
* @element ak-hidden-text-input
* @class AkHiddenTextInput
*
* A text-input field with a visibility control, so you can show/hide sensitive fields.
*
* ## CSS Parts
* @csspart container - The main container div
* @csspart input - The input element
* @csspart toggle - The visibility toggle button
*
*/
@customElement("ak-hidden-textarea-input")
export class AkHiddenTextAreaInput
extends AkHiddenTextInput<HTMLTextAreaElement>
implements AkHiddenTextAreaInputProps
{
/* These are mostly just forwarded to the textarea component. */
/**
* @property
* @attribute
*/
@property({ type: Number })
rows?: number = 4;
/**
* @property
* @attribute
*/
@property({ type: Number })
cols?: number;
/**
* @property
* @attribute
*
* You want `resize=true` so that the resize value is visible in the component tag, activating
* the CSS associated with these values.
*/
@property({ type: String, reflect: true })
resize?: "none" | "both" | "horizontal" | "vertical" = "vertical";
/**
* @property
* @attribute
*/
@property({ type: String })
wrap?: "soft" | "hard" | "off" = "soft";
@query("#main > textarea")
protected inputField!: HTMLTextAreaElement;
get displayValue() {
const value = this.value ?? "";
if (this.revealed) {
return value;
}
return value
.split("\n")
.reduce((acc: string[], line: string) => [...acc, "*".repeat(line.length)], [])
.join("\n");
}
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
// in the LightDom so the inner components actually inherit styling, the normal `css` options
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
// refresh.
protected override renderInputField(setValue: (ev: InputEvent) => void, code: boolean) {
const wrap = this.revealed ? this.wrap : "soft";
return html`
<textarea
style="flex: 1 1 auto; min-width: 0;"
part="textarea"
@input=${setValue}
placeholder=${ifDefined(this.placeholder)}
rows=${ifDefined(this.rows)}
cols=${ifDefined(this.cols)}
wrap=${ifDefined(wrap)}
class=${classMap({
"pf-c-form-control": true,
"pf-m-monospace": code,
})}
spellcheck=${code ? "false" : "true"}
?required=${this.required}
>
${this.displayValue}</textarea
>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-hidden-textarea-input": AkHiddenTextAreaInput;
}
}

View File

@ -8,8 +8,8 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { HorizontalLightComponent } from "./HorizontalLightComponent";
@customElement("ak-private-text-input")
export class AkPrivateTextInput extends HorizontalLightComponent<string> {
@customElement("ak-secret-text-input")
export class AkSecretTextInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
public value = "";
@ -23,7 +23,7 @@ export class AkPrivateTextInput extends HorizontalLightComponent<string> {
this.revealed = true;
}
#renderPrivateInput() {
#renderSecretInput() {
return html`<div class="pf-c-form__horizontal-group" @click=${() => this.#onReveal()}>
<input
class="pf-c-form-control"
@ -60,14 +60,14 @@ export class AkPrivateTextInput extends HorizontalLightComponent<string> {
}
public override renderControl() {
return this.revealed ? this.renderVisibleInput() : this.#renderPrivateInput();
return this.revealed ? this.renderVisibleInput() : this.#renderSecretInput();
}
}
export default AkPrivateTextInput;
export default AkSecretTextInput;
declare global {
interface HTMLElementTagNameMap {
"ak-private-text-input": AkPrivateTextInput;
"ak-secret-text-input": AkSecretTextInput;
}
}

View File

@ -5,10 +5,10 @@ import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { AkPrivateTextInput } from "./ak-private-text-input.js";
import { AkSecretTextInput } from "./ak-secret-text-input.js";
@customElement("ak-private-textarea-input")
export class AkPrivateTextAreaInput extends AkPrivateTextInput {
@customElement("ak-secret-textarea-input")
export class AkSecretTextAreaInput extends AkSecretTextInput {
protected override renderVisibleInput() {
const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => {
@ -34,10 +34,10 @@ export class AkPrivateTextAreaInput extends AkPrivateTextInput {
}
}
export default AkPrivateTextAreaInput;
export default AkSecretTextAreaInput;
declare global {
interface HTMLElementTagNameMap {
"ak-private-textarea-input": AkPrivateTextAreaInput;
"ak-secret-textarea-input": AkSecretTextAreaInput;
}
}

View File

@ -0,0 +1,89 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface VisibilityToggleProps {
open: boolean;
disabled: boolean;
showMessage: string;
hideMessage: string;
}
/**
* @component ak-visibility-toggle
* @class VisibilityToggle
*
* A straightforward two-state iconic button we use in a few places as way of telling users to hide
* or show something secret, such as a password or private key. Expects the client to manage its
* state.
*
* @events
* - click: when the toggle is clicked.
*/
@customElement("ak-visibility-toggle")
export class VisibilityToggle extends AKElement implements VisibilityToggleProps {
static get styles() {
return [PFBase, PFButton];
}
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* @property
* @attribute
*/
@property({ type: Boolean, reflect: true })
disabled = false;
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "show-message" })
showMessage = msg("Show field content");
/**
* @property
* @attribute
*/
@property({ type: String, attribute: "hide-message" })
hideMessage = msg("Hide field content");
render() {
const [label, icon] = this.open
? [this.hideMessage, "fa-eye"]
: [this.showMessage, "fa-eye-slash"];
const onClick = (ev: PointerEvent) => {
ev.stopPropagation();
this.dispatchEvent(new PointerEvent(ev.type, ev));
};
return html`<button
aria-label=${label}
title=${label}
@click=${onClick}
?disabled=${this.disabled}
class="pf-c-button pf-m-control"
type="button"
>
<i class="fas ${icon}" aria-hidden="true"></i>
</button>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-visibility-toggle": VisibilityToggle;
}
}

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../ak-hidden-text-input";
import { type AkHiddenTextInput, type AkHiddenTextInputProps } from "../ak-hidden-text-input.js";
const metadata: Meta<AkHiddenTextInputProps> = {
title: "Components / <ak-hidden-text-input>",
component: "ak-hidden-text-input",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
# Hidden Text Input Component
A text-input field with a visibility control, so you can show/hide sensitive fields.
`,
},
},
layout: "padded",
},
argTypes: {
label: {
control: "text",
description: "Label text for the input field",
},
value: {
control: "text",
description: "Current value of the input",
},
revealed: {
control: "boolean",
description: "Whether the text is currently visible",
},
placeholder: {
control: "text",
description: "Placeholder text for the input",
},
required: {
control: "boolean",
description: "Whether the input is required",
},
inputHint: {
control: "select",
options: ["text", "code"],
description: "Input type hint for styling and behavior",
},
showMessage: {
control: "text",
description: "Custom message for show action",
},
hideMessage: {
control: "text",
description: "Custom message for hide action",
},
},
};
export default metadata;
type Story = StoryObj<AkHiddenTextInput>;
const Template: Story = {
args: {
label: "Hidden Text Input",
value: "",
revealed: false,
},
render: (args) => html`
<ak-hidden-text-input
label=${ifDefined(args.label)}
value=${ifDefined(args.value)}
?revealed=${args.revealed}
placeholder=${ifDefined(args.placeholder)}
?required=${args.required}
input-hint=${ifDefined(args.inputHint)}
show-message=${ifDefined(args.showMessage)}
hide-message=${ifDefined(args.hideMessage)}
></ak-hidden-text-input>
`,
};
export const Password: Story = {
...Template,
args: {
label: "Password",
placeholder: "Enter your password",
required: true,
},
};

View File

@ -0,0 +1,140 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../ak-hidden-textarea-input";
import {
type AkHiddenTextAreaInput,
type AkHiddenTextAreaInputProps,
} from "../ak-hidden-textarea-input.js";
const metadata: Meta<AkHiddenTextAreaInputProps> = {
title: "Components / <ak-hidden-textarea-input>",
component: "ak-hidden-textarea-input",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
# Hidden Textarea Input Component
A textarea input field with a visibility control, so you can show/hide sensitive fields.
`,
},
},
layout: "padded",
},
argTypes: {
label: {
control: "text",
description: "Label text for the input field",
},
value: {
control: "text",
description: "Current value of the input",
},
revealed: {
control: "boolean",
description: "Whether the text is currently visible",
},
placeholder: {
control: "text",
description: "Placeholder text for the input",
},
required: {
control: "boolean",
description: "Whether the input is required",
},
inputHint: {
control: "select",
options: ["text", "code"],
description: "Input type hint for styling and behavior",
},
showMessage: {
control: "text",
description: "Custom message for show action",
},
hideMessage: {
control: "text",
description: "Custom message for hide action",
},
rows: {
control: { type: "number", min: 1, max: 50 },
description: "Number of visible text lines",
},
cols: {
control: { type: "number", min: 10, max: 200 },
description: "Number of visible character width",
},
resize: {
control: "select",
options: ["none", "both", "horizontal", "vertical"],
description: "How the textarea can be resized",
},
wrap: {
control: "select",
options: ["soft", "hard", "off"],
description: "Text wrapping behavior",
},
},
};
export default metadata;
type Story = StoryObj<AkHiddenTextAreaInput>;
const Template: Story = {
args: {
label: "Hidden Textarea Input",
value: "",
revealed: false,
rows: 4,
},
render: (args) => html`
<ak-hidden-textarea-input
label=${ifDefined(args.label)}
value=${ifDefined(args.value)}
?revealed=${args.revealed}
placeholder=${ifDefined(args.placeholder)}
rows=${ifDefined(args.rows)}
cols=${ifDefined(args.cols)}
resize=${ifDefined(args.resize)}
wrap=${ifDefined(args.wrap)}
?required=${args.required}
input-hint=${ifDefined(args.inputHint)}
show-message=${ifDefined(args.showMessage)}
hide-message=${ifDefined(args.hideMessage)}
></ak-hidden-textarea-input>
`,
};
export const SslCertificate: Story = {
...Template,
args: {
label: "SSL Certificate",
value: `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTcwNTEwMTk0MDA2WhcNMTgwNTEwMTk0MDA2WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMB4XDTE3MDUxMDE5NDAwNloXDTE4MDUxMDE5
NDAwNlowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNV
BAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBALdUlNS31SzxwoFShahGfjHj6GgpcVbzL1Siq0Pqnf82T6M2
EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggE
BAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqn
f82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgM
BAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeM
Hyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDu
neMLzAgMBAAECggEBAJkPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAECggEBAJ
kPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAE=
-----END CERTIFICATE-----`,
inputHint: "code",
rows: 15,
resize: "vertical",
showMessage: "Show certificate content",
hideMessage: "Hide certificate content",
autocomplete: "off",
},
};

View File

@ -0,0 +1,121 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../ak-visibility-toggle";
import { type VisibilityToggle, type VisibilityToggleProps } from "../ak-visibility-toggle.js";
const metadata: Meta<VisibilityToggleProps> = {
title: "Elements/<ak-visibility-toggle>",
component: "ak-visibility-toggle",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
# Visibility Toggle Component
A straightforward two-state iconic button for toggling the visibility of sensitive content such as passwords, private keys, or other secret information.
- Use for sensitive content that users might want to temporarily reveal
- There are default hide/show messages for screen readers, but they can be overridden
- Clients always handle the state
- The \`open\` state is false by default; we assume you want sensitive content hidden at start
`,
},
},
layout: "padded",
},
argTypes: {
open: {
control: "boolean",
description: "Whether the toggle is in the 'show' state (true) or 'hide' state (false)",
},
showMessage: {
control: "text",
description:
'Message for screen readers when in hide state (default: "Show field content")',
},
hideMessage: {
control: "text",
description:
'Message for screen readers when in show state (default: "Hide field content")',
},
disabled: {
control: "boolean",
description: "Whether the button should be disabled (for demo purposes)",
},
},
};
export default metadata;
type Story = StoryObj<VisibilityToggle>;
const Template: Story = {
args: {
open: false,
showMessage: "Show field content",
hideMessage: "Hide field content",
},
render: (args) => html`
<ak-visibility-toggle
?open=${args.open}
show-message=${ifDefined(args.showMessage)}
hide-message=${ifDefined(args.hideMessage)}
@click=${(e: Event) => {
const target = e.target as VisibilityToggle;
target.open = !target.open;
// In a real application, you would also toggle the visibility
// of the associated content here
}}
></ak-visibility-toggle>
`,
};
// Password field integration example
export const PasswordFieldExample: Story = {
args: {
showMessage: "Reveal password",
hideMessage: "Conceal password",
},
render: () => {
let isVisible = false;
const toggleVisibility = (e: Event) => {
isVisible = !isVisible;
const toggle = e.target as VisibilityToggle;
const passwordField = document.querySelector("#demo-password") as HTMLInputElement;
toggle.open = isVisible;
if (passwordField) {
passwordField.type = isVisible ? "text" : "password";
}
};
return html`
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 300px;">
<label for="demo-password" style="font-weight: bold;">Password:</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input
id="demo-password"
type="password"
value="supersecretpassword123"
style="flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px;"
readonly
/>
<ak-visibility-toggle
?open=${isVisible}
show-message="Show password"
hide-message="Hide password"
@click=${toggleVisibility}
></ak-visibility-toggle>
</div>
<p style="font-size: 0.875rem; color: #666;">
Click the eye icon to toggle password visibility
</p>
</div>
`;
},
};

View File

@ -16,8 +16,12 @@ import { property } from "lit/decorators.js";
import { UiThemeEnum } from "@goauthentik/api";
export interface AKElementProps {
activeTheme: ResolvedUITheme;
}
@localized()
export class AKElement extends LitElement {
export class AKElement extends LitElement implements AKElementProps {
//#region Static Properties
public static styles?: Array<CSSResult | CSSModule>;