Compare commits

..

1 Commits

Author SHA1 Message Date
b0e6558a4f current version
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-13 16:09:00 +02:00
37 changed files with 609 additions and 1169 deletions

View File

@ -69,6 +69,7 @@ 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"
@ -454,6 +455,7 @@ 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,7 +6,8 @@ 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
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
class FlowInterfaceView(InterfaceView):
@ -14,6 +15,12 @@ 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,3 +39,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
default = True
mountpoint = "policy/"

View File

@ -0,0 +1,89 @@
{% 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

@ -0,0 +1,121 @@
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,7 +1,14 @@
"""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,23 +1,37 @@
"""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
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.utils.translation import gettext as _
from django.views.generic.base import View
from django.views.generic.base import TemplateView, View
from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
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.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):
@ -125,3 +139,65 @@ 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 PolicyAccessView, RequestValidationError
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN,
PKCE_METHOD_S256,
@ -326,7 +326,7 @@ class OAuthAuthorizationParams:
return code
class AuthorizationFlowInitView(PolicyAccessView):
class AuthorizationFlowInitView(BufferedPolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams

View File

@ -18,14 +18,11 @@ 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 PolicyAccessView
from authentik.policies.views import BufferedPolicyAccessView
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(PolicyAccessView):
class RACStartView(BufferedPolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint
@ -112,15 +109,10 @@ 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=settings or {},
settings=self.executor.plan.context.get("connection_settings", {}),
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"
SESSION_KEY_AUTH_N_REQUEST = "authentik/providers/saml/authn_request"
SESSION_KEY_LOGOUT_REQUEST = "authentik/providers/saml/logout_request"
PLAN_CONTEXT_SAML_AUTH_N_REQUEST = "authentik/providers/saml/authn_request"
PLAN_CONTEXT_SAML_LOGOUT_REQUEST = "authentik/providers/saml/logout_request"
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
@ -50,10 +50,11 @@ 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 SESSION_KEY_AUTH_N_REQUEST not in self.request.session:
if PLAN_CONTEXT_SAML_AUTH_N_REQUEST not in self.executor.plan.context:
self.logger.warning("No AuthNRequest in context")
return self.executor.stage_invalid()
auth_n_request: AuthNRequest = self.request.session.pop(SESSION_KEY_AUTH_N_REQUEST)
auth_n_request: AuthNRequest = self.executor.plan.context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST]
try:
response = AssertionProcessor(provider, request, auth_n_request).build_response()
except SAMLException as exc:
@ -106,6 +107,3 @@ 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,6 +33,10 @@ 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(
@ -59,6 +63,7 @@ class SAMLSLOView(PolicyAccessView):
request,
{
PLAN_CONTEXT_APPLICATION: self.application,
**self.plan_context,
},
)
plan.append_stage(in_memory_stage(SessionEndStage))
@ -83,7 +88,7 @@ class SAMLSLOBindingRedirectView(SAMLSLOView):
self.request.GET[REQUEST_KEY_SAML_REQUEST],
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
)
self.request.session[SESSION_KEY_LOGOUT_REQUEST] = logout_request
self.plan_context[PLAN_CONTEXT_SAML_LOGOUT_REQUEST] = logout_request
except CannotHandleAssertion as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
@ -111,7 +116,7 @@ class SAMLSLOBindingPOSTView(SAMLSLOView):
payload[REQUEST_KEY_SAML_REQUEST],
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
)
self.request.session[SESSION_KEY_LOGOUT_REQUEST] = logout_request
self.plan_context[PLAN_CONTEXT_SAML_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 PolicyAccessView
from authentik.policies.views import BufferedPolicyAccessView
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,10 +35,14 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger()
class SAMLSSOView(PolicyAccessView):
class SAMLSSOView(BufferedPolicyAccessView):
"""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(
@ -68,6 +72,7 @@ class SAMLSSOView(PolicyAccessView):
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:
@ -83,7 +88,7 @@ class SAMLSSOView(PolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't
override .dispatch easily because PolicyAccessView's dispatch"""
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
return self.get(request, application_slug)
@ -103,7 +108,7 @@ class SAMLSSOBindingRedirectView(SAMLSSOView):
self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
)
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
@ -137,7 +142,7 @@ class SAMLSSOBindingPOSTView(SAMLSSOView):
payload[REQUEST_KEY_SAML_REQUEST],
payload.get(REQUEST_KEY_RELAY_STATE),
)
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc:
LOGGER.info(str(exc))
return bad_request_message(self.request, str(exc))
@ -151,4 +156,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.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request

View File

@ -410,3 +410,77 @@ 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):
def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs):
"""Setup client saml-sp container which we test SAML against"""
metadata_url = (
self.url(
@ -40,6 +40,7 @@ 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,
},
)
@ -111,6 +112,74 @@ 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",
@ -450,3 +519,81 @@ 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,7 +51,6 @@
"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",
@ -2588,57 +2587,6 @@
"@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",
@ -16992,12 +16940,6 @@
"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",
@ -21690,57 +21632,6 @@
"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,7 +122,6 @@
"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

@ -1,172 +0,0 @@
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,6 +1,5 @@
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";
@ -22,7 +21,6 @@ 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";
@ -121,10 +119,6 @@ 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);
}
`,
];
@ -196,11 +190,6 @@ 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-secret-textarea-input.js";
import "@goauthentik/components/ak-private-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-secret-textarea-input
<ak-private-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-secret-textarea-input>
<ak-secret-textarea-input
></ak-private-textarea-input>
<ak-private-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-secret-textarea-input>`;
></ak-private-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-secret-textarea-input.js";
import "@goauthentik/components/ak-private-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-secret-textarea-input
<ak-private-textarea-input
name="key"
?revealed=${this.instance === undefined}
label=${msg("License key")}
input-hint="code"
>
</ak-secret-textarea-input>`;
</ak-private-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-secret-text-input.js";
import "@goauthentik/components/ak-secret-textarea-input.js";
import "@goauthentik/components/ak-private-text-input.js";
import "@goauthentik/components/ak-private-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-secret-text-input
<ak-private-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-secret-text-input>
<ak-secret-textarea-input
></ak-private-text-input>
<ak-private-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-secret-textarea-input>
></ak-private-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-secret-textarea-input
<ak-private-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-secret-textarea-input>
></ak-private-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-secret-text-input.js";
import "@goauthentik/components/ak-private-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-secret-text-input
<ak-private-text-input
label=${msg("Bind Password")}
name="bindPassword"
?revealed=${this.instance === undefined}
></ak-secret-text-input>
></ak-private-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-secret-textarea-input
<ak-private-textarea-input
label=${msg("Consumer secret")}
name="consumerSecret"
input-hint="code"
help=${msg("Also known as Client Secret.")}
required
?revealed=${this.instance === undefined}
></ak-secret-textarea-input>
></ak-private-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-secret-text-input.js";
import "@goauthentik/components/ak-private-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-secret-text-input
<ak-private-text-input
name="clientSecret"
label=${msg("Secret key")}
input-hint="code"
required
?revealed=${this.instance === undefined}
></ak-secret-text-input>
></ak-private-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-secret-text-input
<ak-private-text-input
name="adminSecretKey"
label=${msg("Secret key")}
input-hint="code"
?revealed=${this.instance === undefined}
></ak-secret-text-input>
></ak-private-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-secret-text-input.js";
import "@goauthentik/components/ak-private-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-secret-text-input
<ak-private-text-input
name="password"
label=${msg("SMTP Password")}
?revealed=${this.instance === undefined}
></ak-secret-text-input>
></ak-private-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-secret-text-input.js";
import "@goauthentik/components/ak-private-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-secret-text-input
<ak-private-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-secret-text-input>
></ak-private-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-secret-text-input.js";
import "@goauthentik/components/ak-private-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-secret-text-input
<ak-private-text-input
label=${msg("SMTP Password")}
name="password"
?revealed=${this.instance === undefined}
></ak-secret-text-input>
></ak-private-text-input>
<ak-form-element-horizontal name="useTls">
<label class="pf-c-switch">
<input

View File

@ -1,4 +1,4 @@
import { AKElement, type AKElementProps } from "@goauthentik/elements/Base";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/HorizontalFormElement.js";
import { TemplateResult, html, nothing } from "lit";
@ -6,19 +6,6 @@ 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
@ -31,81 +18,37 @@ 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 = "";
protected renderControl() {
renderControl() {
throw new Error("Must be implemented in a subclass");
}

View File

@ -1,159 +0,0 @@
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

@ -1,128 +0,0 @@
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-secret-text-input")
export class AkSecretTextInput extends HorizontalLightComponent<string> {
@customElement("ak-private-text-input")
export class AkPrivateTextInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
public value = "";
@ -23,7 +23,7 @@ export class AkSecretTextInput extends HorizontalLightComponent<string> {
this.revealed = true;
}
#renderSecretInput() {
#renderPrivateInput() {
return html`<div class="pf-c-form__horizontal-group" @click=${() => this.#onReveal()}>
<input
class="pf-c-form-control"
@ -60,14 +60,14 @@ export class AkSecretTextInput extends HorizontalLightComponent<string> {
}
public override renderControl() {
return this.revealed ? this.renderVisibleInput() : this.#renderSecretInput();
return this.revealed ? this.renderVisibleInput() : this.#renderPrivateInput();
}
}
export default AkSecretTextInput;
export default AkPrivateTextInput;
declare global {
interface HTMLElementTagNameMap {
"ak-secret-text-input": AkSecretTextInput;
"ak-private-text-input": AkPrivateTextInput;
}
}

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 { AkSecretTextInput } from "./ak-secret-text-input.js";
import { AkPrivateTextInput } from "./ak-private-text-input.js";
@customElement("ak-secret-textarea-input")
export class AkSecretTextAreaInput extends AkSecretTextInput {
@customElement("ak-private-textarea-input")
export class AkPrivateTextAreaInput extends AkPrivateTextInput {
protected override renderVisibleInput() {
const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => {
@ -34,10 +34,10 @@ export class AkSecretTextAreaInput extends AkSecretTextInput {
}
}
export default AkSecretTextAreaInput;
export default AkPrivateTextAreaInput;
declare global {
interface HTMLElementTagNameMap {
"ak-secret-textarea-input": AkSecretTextAreaInput;
"ak-private-textarea-input": AkPrivateTextAreaInput;
}
}

View File

@ -1,89 +0,0 @@
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

@ -1,93 +0,0 @@
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

@ -1,140 +0,0 @@
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

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