Compare commits

...

6 Commits

Author SHA1 Message Date
Teffen Ellis
12d8dfc42b Adjust color. 2026-05-07 15:19:36 +02:00
Teffen Ellis
062f03fd0c Adjust page padding, banners. 2026-05-07 15:19:29 +02:00
Teffen Ellis
ef2f316cda Flesh out sidebar invoker. 2026-05-07 15:09:00 +02:00
Teffen Ellis
e50f093685 web/rac: Ignore empty remote clipboard payloads (#22067)
web/rac: ignore empty remote clipboard payloads

Some remote sessions (notably SSH) push empty or whitespace-only
clipboard updates that overwrite the user's local clipboard, leaving
subsequent paste attempts with nothing to deliver. Filter those payloads
in the StringReader.onend callback so the local clipboard is preserved.

Closes #21439

Co-authored-by: Agent (authentik-i21439-featured-elevated-kobicha) <279763771+playpen-agent@users.noreply.github.com>
2026-05-06 20:34:34 +02:00
Jens L.
cf05037761 api: make ordering null-aware (#22099)
* api: make ordering null-aware

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

* add types

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-06 20:34:24 +02:00
Jens L.
4d035d1eda web/admin: remove side-padding on user paths (#22088)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-05-06 19:33:46 +02:00
15 changed files with 222 additions and 51 deletions

36
authentik/api/ordering.py Normal file
View File

@@ -0,0 +1,36 @@
from django.db.models import F, QuerySet
from rest_framework.filters import OrderingFilter
from rest_framework.request import Request
from rest_framework.views import APIView
class NullsAwareOrderingFilter(OrderingFilter):
"""OrderingFilter that sorts NULL values consistently.
For any nullable field, NULLs are treated as the smallest possible value:
- ascending → NULLs appear first (nulls_first=True)
- descending → NULLs appear last (nulls_last=True)
"""
def _nullable_field_names(self, queryset: QuerySet) -> set[str]:
return {f.name for f in queryset.model._meta.get_fields() if hasattr(f, "null") and f.null}
def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView):
queryset = super().filter_queryset(request, queryset, view)
ordering = queryset.query.order_by
if not ordering:
return queryset
nullable = self._nullable_field_names(queryset)
new_ordering = []
changed = False
for term in ordering:
name = term.lstrip("-")
if name in nullable:
changed = True
if term.startswith("-"):
new_ordering.append(F(name).desc(nulls_last=True))
else:
new_ordering.append(F(name).asc(nulls_first=True))
else:
new_ordering.append(term)
return queryset.order_by(*new_ordering) if changed else queryset

View File

@@ -0,0 +1,59 @@
from django.db.models import OrderBy
from django.test import TestCase
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from authentik.api.ordering import NullsAwareOrderingFilter
from authentik.core.models import Token, User
class MockView:
ordering_fields = "__all__"
ordering = None
class TestNullsAwareOrderingFilter(TestCase):
def setUp(self):
self.filter = NullsAwareOrderingFilter()
self.view = MockView()
factory = APIRequestFactory()
self._req = lambda ordering: Request(factory.get("/", {"ordering": ordering}))
def _order_by(self, model, ordering):
qs = model.objects.all()
return self.filter.filter_queryset(self._req(ordering), qs, self.view).query.order_by
def test_nullable_asc_nulls_first(self):
"""Ascending sort on a nullable field rewrites to nulls_first=True."""
(expr,) = self._order_by(User, "last_login")
self.assertIsInstance(expr, OrderBy)
self.assertFalse(expr.descending)
self.assertTrue(expr.nulls_first)
def test_nullable_desc_nulls_last(self):
"""Descending sort on a nullable field rewrites to nulls_last=True."""
(expr,) = self._order_by(User, "-last_login")
self.assertIsInstance(expr, OrderBy)
self.assertTrue(expr.descending)
self.assertTrue(expr.nulls_last)
def test_non_nullable_passes_through(self):
"""Non-nullable fields are left as plain string terms."""
(expr,) = self._order_by(User, "username")
self.assertEqual(expr, "username")
def test_mixed_ordering(self):
"""Only nullable terms are rewritten; non-nullable terms pass through unchanged."""
first, second = self._order_by(User, "username,-last_login")
self.assertEqual(first, "username")
self.assertIsInstance(second, OrderBy)
self.assertTrue(second.descending)
self.assertTrue(second.nulls_last)
def test_expires_nullable(self):
"""expires on ExpiringModel is nullable and is rewritten correctly."""
(expr,) = self._order_by(Token, "-expires")
self.assertIsInstance(expr, OrderBy)
self.assertTrue(expr.descending)
self.assertTrue(expr.nulls_last)

View File

@@ -221,7 +221,7 @@ REST_FRAMEWORK = {
"authentik.api.search.ql.QLSearch",
"authentik.rbac.filters.ObjectFilter",
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
"authentik.api.ordering.NullsAwareOrderingFilter",
],
"DEFAULT_PERMISSION_CLASSES": ("authentik.rbac.permissions.ObjectPermissions",),
"DEFAULT_AUTHENTICATION_CLASSES": (

View File

@@ -35,8 +35,41 @@ ak-sidebar-item:active ak-sidebar-item::part(list-item) {
background-color: transparent !important;
}
.command-palette-trigger {
[part="command-palette-trigger"] {
--BackgroundColor: var(--pf-global--BackgroundColor--150);
background: var(--BackgroundColor);
border-radius: var(--pf-global--BorderRadius--sm);
border: 0.5px solid var(--pf-global--BorderColor--100);
cursor: pointer;
display: grid;
gap: var(--pf-global--spacer--sm);
grid-template-columns: [icon] auto [label] 1fr [shortcut-hint] auto;
justify-items: start;
align-items: center;
margin-block-end: var(--pf-global--spacer--form-element);
margin-block-start: var(--pf-global--spacer--sm);
margin-inline: var(--pf-global--spacer--sm);
padding-block: var(--pf-global--spacer--form-element);
padding-inline-start: var(--pf-global--spacer--sm);
user-select: none;
z-index: 1;
color: var(--pf-global--Color--200);
padding-inline-end: var(--pf-global--spacer--sm);
position: relative;
.placeholder {
font-style: italic;
font-size: var(--pf-global--FontSize--sm);
}
&:hover {
--BackgroundColor: var(--pf-global--BackgroundColor--200);
}
.icon {
display: block;
height: var(--pf-global--icon--FontSize--md);
fill: currentColor;
stroke: currentColor;
}
}

View File

@@ -269,13 +269,12 @@ export class AdminInterface extends WithCapabilitiesConfig(
<i aria-hidden="true" class="fas fa-bars"></i>
</button>
${this.renderCommandPaletteButton()}
<ak-version-banner></ak-version-banner>
<ak-enterprise-status interface="admin"></ak-enterprise-status>
</ak-page-navbar>
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}"
>${renderSidebarItems(this.entries)}
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}">
${this.renderCommandPaletteButton()} ${renderSidebarItems(this.entries)}
${this.can(CapabilitiesEnum.IsEnterprise)
? renderSidebarItems(createAdminSidebarEnterpriseEntries())
: nothing}
@@ -321,36 +320,31 @@ export class AdminInterface extends WithCapabilitiesConfig(
const primaryModifierKey = macOS ? "⌘" : "Ctrl";
return html`<button
slot="nav-buttons"
slot="before-items"
part="command-palette-trigger"
@click=${this.commandPalette.showListener}
class="pf-c-button pf-m-plain command-palette-trigger"
type="button"
aria-label=${msg("Open Command Palette", {
id: "command-palette-trigger-label",
id: "command-palette-trigger-label-mobile",
desc: "Label for the button that opens the command palette",
})}
>
<pf-tooltip position="top-end">
<div slot="content" class="ak-tooltip__content--inline">
${msg("Open Command Palette", {
id: "command-palette-trigger-tooltip",
desc: "Tooltip for the button that opens the command palette",
})}
<div class="ak-c-kbd"><kbd>${primaryModifierKey}</kbd> + <kbd>K</kbd></div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
class="ak-c-vector-icon"
role="img"
viewBox="0 0 32 32"
>
<path
d="M26 4.01H6a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-20a2 2 0 0 0-2-2m0 2v4H6v-4Zm-20 20v-14h20v14Z"
/>
<path d="m10.76 16.18 2.82 2.83-2.82 2.83 1.41 1.41 4.24-4.24-4.24-4.24z" />
</svg>
</pf-tooltip>
<svg
class="icon"
role="img"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
>
<path
d="m29 27.586-7.552-7.552a11.018 11.018 0 1 0-1.414 1.414L27.586 29ZM4 13a9 9 0 1 1 9 9 9.01 9.01 0 0 1-9-9"
/>
</svg>
<div class="placeholder">${msg("Search...")}</div>
<div class="ak-c-kbd">
<kbd>${primaryModifierKey}</kbd> <span class="ak-c-kbd__plus">+</span>
<kbd>K</kbd>
</div>
</button>`;
});
}

View File

@@ -81,7 +81,7 @@ export class DeviceListPage extends TablePage<EndpointDevice> {
renderSectionBefore() {
return html`
<div class="pf-c-banner pf-m-info">
<div class="pf-c-banner pf-m-info ak-m-inset">
${msg("Endpoint Devices are in preview.")}
<a href="mailto:hello+feature/platform@goauthentik.io"
>${msg("Send us feedback!")}</a

View File

@@ -13,7 +13,7 @@ export class LifecyclePreviewBanner extends AKElement {
static styles = [PFBanner];
public render(): TemplateResult {
return html`<div class="pf-c-banner pf-m-info">
return html`<div class="pf-c-banner pf-m-info ak-m-inset">
${msg("Object Lifecycle Management is in preview.")}
<a href="mailto:hello+feature/lifecycle@goauthentik.io">${msg("Send us feedback!")}</a>
</div>`;

View File

@@ -37,7 +37,7 @@ export class AuthenticatorEndpointGDTCStageForm extends BaseStageForm<Authentica
static styles = [...super.styles, PFBanner];
protected override renderForm(): TemplateResult {
return html`<div class="pf-c-banner pf-m-info">
return html`<div class="pf-c-banner pf-m-info ak-m-inset">
${msg("Endpoint Google Chrome Device Trust is in preview.")}
<a href="mailto:hello+feature/gdtc@goauthentik.io">${msg("Send us feedback!")}</a>
</div>

View File

@@ -73,6 +73,10 @@ export class UserListPage extends WithLicenseSummary(
max-width: var(--pf-c-avatar--Width);
vertical-align: middle;
}
.pf-c-card.tree .pf-c-card__body {
padding-left: 0;
padding-right: 0;
}
`,
];
@@ -92,7 +96,7 @@ export class UserListPage extends WithLicenseSummary(
public pageIcon = "pf-icon pf-icon-user";
@property({ type: String })
public order = "last_login";
public order = "-last_login";
@property({ type: String })
public activePath: string;
@@ -368,7 +372,7 @@ export class UserListPage extends WithLicenseSummary(
protected renderSidebarBefore(): TemplateResult {
return html`<aside aria-labelledby="sidebar-left-panel-header" class="pf-c-sidebar__panel">
<div class="pf-c-card">
<div class="pf-c-card tree">
<div
role="heading"
aria-level="2"

View File

@@ -35,7 +35,6 @@
.pf-c-nav__list {
flex-grow: 1;
overflow-y: auto;
padding-block-start: var(--pf-global--spacer--sm);
}
.pf-c-nav__link.pf-m-current::after,

View File

@@ -20,10 +20,22 @@ export class Sidebar extends AKElement {
];
@property({ type: Boolean })
hidden = false;
public hidden = false;
protected defaultSlot: HTMLSlotElement;
protected beforeItemsSlot: HTMLSlotElement;
constructor() {
super();
this.defaultSlot = this.ownerDocument.createElement("slot");
this.beforeItemsSlot = this.ownerDocument.createElement("slot");
this.beforeItemsSlot.name = "before-items";
}
render(): TemplateResult {
return html`<div part="nav" class="pf-c-nav" role="presentation">
${this.beforeItemsSlot}
<ul
id="global-nav"
?hidden=${this.hidden}
@@ -32,7 +44,7 @@ export class Sidebar extends AKElement {
class="pf-c-nav__list ak-m-thin-scrollbar ak-m-scroll-shadows"
part="list"
>
<slot></slot>
${this.defaultSlot}
</ul>
<ak-sidebar-version
exportparts="trigger:about-dialog-trigger, button-content:about-dialog-button-content, product-name, product-version"

View File

@@ -2,6 +2,8 @@ import "#elements/LoadingOverlay";
import Styles from "./index.entrypoint.css";
import { writeToClipboard } from "#common/clipboard";
import { Interface } from "#elements/Interface";
import { WithBrandConfig } from "#elements/mixins/branding";
@@ -155,22 +157,35 @@ export class RacInterface extends WithBrandConfig(Interface) {
if (/^text\//.exec(mimetype)) {
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => {
data += text;
};
reader.onend = () => {
this._previousClipboardValue = data;
navigator.clipboard.writeText(data);
const trimmed = data.trim();
// Some remote sessions (notably SSH) push empty clipboard
// payloads that would otherwise clobber the user's local
// clipboard, breaking subsequent paste attempts. Ignore
// them so the local clipboard remains intact.
if (!trimmed) {
console.debug("authentik/rac: ignored empty remote clipboard payload");
return;
}
this._previousClipboardValue = trimmed;
writeToClipboard(trimmed);
};
} else {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
const item = new ClipboardItem({
[blob.type]: blob,
});
writeToClipboard(item);
};
}
console.debug("authentik/rac: updated clipboard from remote");

View File

@@ -91,18 +91,19 @@
/* #region Keyboard */
.ak-c-kbd {
--ak-c-kbd--ShadowColor: var(--pf-global--BackgroundColor--dark-transparent-100);
--ak-c-kbd--InsetColor: var(--pf-global--Color--light-300);
--ak-c-kbd--ShadowColor: var(--pf-global--BackgroundColor--dark-transparent-200);
--ak-c-kbd--InsetColor: var(--pf-global--BackgroundColor--100);
display: flex;
align-items: center;
line-height: 1;
gap: 0.125em;
kbd {
user-select: none;
text-rendering: optimizeLegibility;
letter-spacing: 0.05em;
padding: 0.25rem 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
box-shadow:
0 1px 1px var(--ak-c-kbd--ShadowColor),
@@ -112,9 +113,15 @@
font-weight: bold;
line-height: 1;
white-space: nowrap;
background-color: var(--pf-global--BackgroundColor--light-100);
color: var(--pf-global--Color--dark-100);
background-color: var(--pf-global--BackgroundColor--150);
color: var(--pf-global--Color--200);
font-family: var(--pf-global--FontFamily--monospace);
text-align: center;
}
.ak-c-kbd__plus {
font-size: var(--pf-global--FontSize--sm);
color: var(--pf-global--Color--dark-200);
}
}

View File

@@ -13,6 +13,14 @@
}
}
.pf-c-banner.ak-m-inset {
margin-block-start: calc(
var(--pf-global--spacer--xs) + (var(--pf-global--spacer--form-element) / 2)
);
margin-inline: var(--pf-global--spacer--md);
border-radius: var(--pf-global--BorderRadius--sm);
}
:host([theme="dark"]) .pf-c-banner {
&.pf-m-info,
&.pf-m-blue,

View File

@@ -10,6 +10,10 @@
--pf-c-page__sidebar--m-dark--BackgroundColor: var(--pf-global--BackgroundColor--100);
--pf-c-page__sidebar--BackgroundColor: var(--pf-c-page__sidebar--m-light--BackgroundColor);
--pf-c-page__main-section--xl--PaddingTop: var(--pf-global--spacer--md);
--pf-c-page__main-section--xl--PaddingLeft: var(--pf-global--spacer--md);
--pf-c-page__main-section--xl--PaddingRight: var(--pf-global--spacer--md);
--pf-c-page__main-section--xl--PaddingBottom: var(--pf-global--spacer--md);
}
.pf-c-page__drawer {