mirror of
https://github.com/goauthentik/authentik
synced 2026-05-07 23:52:38 +02:00
Compare commits
6 Commits
blueprint_
...
command-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12d8dfc42b | ||
|
|
062f03fd0c | ||
|
|
ef2f316cda | ||
|
|
e50f093685 | ||
|
|
cf05037761 | ||
|
|
4d035d1eda |
36
authentik/api/ordering.py
Normal file
36
authentik/api/ordering.py
Normal 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
|
||||
59
authentik/api/tests/test_ordering.py
Normal file
59
authentik/api/tests/test_ordering.py
Normal 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)
|
||||
@@ -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": (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user