Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,15 +610,14 @@ def uid(self) -> str:

def locale(self, request: HttpRequest | None = None) -> str:
"""Get the locale the user has configured"""
if request and hasattr(request, "LANGUAGE_CODE"):
return request.LANGUAGE_CODE
try:
return self.attributes.get("settings", {}).get("locale", "")

locale = self.attributes.get("settings", {}).get("locale", "")
if locale:
return locale
except Exception as exc: # noqa
LOGGER.warning("Failed to get default locale", exc=exc)
if request:
return request.brand.locale
if request and hasattr(request, "LANGUAGE_CODE"):
return request.LANGUAGE_CODE
return ""

@property
Expand Down
41 changes: 41 additions & 0 deletions authentik/core/tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from unittest.mock import patch

from django.contrib.auth.hashers import make_password
from django.http import HttpRequest
from django.test.testcases import TestCase

from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
Expand Down Expand Up @@ -40,6 +41,46 @@ def test_user_ak_groups_event(self):
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)

def test_locale_user_setting_wins_over_language_code(self):
"""Test the user's saved locale takes precedence over request.LANGUAGE_CODE"""
user = User.objects.create(
username=generate_id(),
attributes={"settings": {"locale": "de"}},
)
request = HttpRequest()
request.LANGUAGE_CODE = "fr"
self.assertEqual(user.locale(request), "de")

def test_locale_falls_back_to_language_code(self):
"""Test request.LANGUAGE_CODE is used when the user has no saved locale"""
user = User.objects.create(username=generate_id())
request = HttpRequest()
request.LANGUAGE_CODE = "fr"
self.assertEqual(user.locale(request), "fr")

def test_locale_empty_user_setting_falls_back_to_language_code(self):
"""Test an empty saved locale does not shadow request.LANGUAGE_CODE"""
user = User.objects.create(
username=generate_id(),
attributes={"settings": {"locale": ""}},
)
request = HttpRequest()
request.LANGUAGE_CODE = "fr"
self.assertEqual(user.locale(request), "fr")

def test_locale_no_request_returns_user_setting(self):
"""Test the user's saved locale is returned when there is no request"""
user = User.objects.create(
username=generate_id(),
attributes={"settings": {"locale": "de"}},
)
self.assertEqual(user.locale(), "de")

def test_locale_no_request_no_setting_returns_empty(self):
"""Test an empty string is returned when there is no request and no saved locale"""
user = User.objects.create(username=generate_id())
self.assertEqual(user.locale(), "")

def test_set_password_from_hash_signal_skips_source_sync_receivers(self):
"""Test hash password updates do not expose a raw password to sync receivers."""
user = User.objects.create(
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unist-util-visit": "^5.1.0",
"urlpattern-polyfill": "^10.1.0",
"vite": "catalog:",
"vitest": "catalog:",
"webcomponent-qr-code": "^2.0.0",
Expand Down
8 changes: 8 additions & 0 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions web/src/elements/router/core/hash-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @file Legacy hash-route redirect shim (temporary).
*
* Translates legacy `#/path;<params>` URLs — both the JSON-blob encoding and
* the `URLSearchParams` encoding — into path-based URLs, and applies the
* translation via `history.replaceState` at boot.
*
* @remarks REMOVAL TARGET: delete this file two releases after the admin
* interface ships on path routing (final step of the client-side routing
* rollout).
*/

import { getRouterConfig } from "#elements/router/core/config";
import { formatInterfacePrefix } from "#elements/router/core/interfaces";
import {
recordToSearchParams,
type RouteParameterRecord,
searchParamsToRecord,
} from "#elements/router/core/parameters";

/**
* Separator between the legacy hash path and its serialized parameters.
*/
const LEGACY_PARAM_SEPARATOR = ";";

export interface HashRouteScope {
base: string;
interfaceName: string;
}

/**
* Decode the serialized-parameter tail of a legacy hash route.
*
* Handles the JSON-blob encoding (`{"page":2}`, possibly percent-encoded) and
* the `URLSearchParams` encoding (`a=1&b=true`).
*/
function decodeLegacyParams(serialized: string | undefined): RouteParameterRecord {
if (!serialized) return {};

const looksLikeJSON = serialized.startsWith("{") || serialized.startsWith("%7B");

if (looksLikeJSON) {
try {
return JSON.parse(decodeURIComponent(serialized)) as RouteParameterRecord;
} catch {
return {};
}
}

return searchParamsToRecord(new URLSearchParams(serialized));
}

/**
* Translate a legacy hash route to a path-based URL.
*
* @param hash The `location.hash` value (including the leading `#`).
* @param scope The deployment base and target interface.
* @returns The translated path + search string, or `null` when `hash` is not a
* legacy route (does not begin with `#/`).
*/
export function translateHashRoute(hash: string, scope: HashRouteScope): string | null {
if (!hash.startsWith("#/")) return null;

const withoutHash = hash.slice(1);
const separatorIndex = withoutHash.indexOf(LEGACY_PARAM_SEPARATOR);

const rawPath = separatorIndex === -1 ? withoutHash : withoutHash.slice(0, separatorIndex);
const rawParams = separatorIndex === -1 ? undefined : withoutHash.slice(separatorIndex + 1);

const segment = rawPath.replace(/^\/+/, "");
const params = decodeLegacyParams(rawParams);
const search = recordToSearchParams(params).toString();

const prefix = formatInterfacePrefix(scope.base, scope.interfaceName);

return `${prefix}${segment}${search ? `?${search}` : ""}`;
}

/**
* Apply the hash-route redirect at boot, if the current URL is a legacy route.
*
* @param target The window whose location/history to read and rewrite.
* @returns `true` when a redirect was applied.
*
* @remarks REMOVAL TARGET: delete with {@linkcode translateHashRoute}.
*/
export function applyHashRedirect(target: Window = window): boolean {
const translated = translateHashRoute(target.location.hash, getRouterConfig());

if (translated === null) return false;

target.history.replaceState(null, "", translated);

return true;
}
64 changes: 64 additions & 0 deletions web/src/elements/router/core/hash-shim.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type HashRouteScope, translateHashRoute } from "./hash-shim.js";

import { describe, expect, it } from "vitest";

const scope: HashRouteScope = { base: "/", interfaceName: "admin" };

describe("translateHashRoute", () => {
it("returns null for non-hash-route URLs", () => {
expect(translateHashRoute("", scope)).toBeNull();
expect(translateHashRoute("#access_token=abc", scope)).toBeNull();
});

it("translates a bare hash path with no params", () => {
expect(translateHashRoute("#/core/applications", scope)).toBe(
"/if/admin/core/applications",
);
});

it("translates a percent-encoded JSON blob", () => {
const hash = "#/core/applications;" + encodeURIComponent(JSON.stringify({ page: 2 }));

expect(translateHashRoute(hash, scope)).toBe("/if/admin/core/applications?page=2");
});

it("translates a raw JSON blob", () => {
expect(translateHashRoute('#/core/applications;{"page":2}', scope)).toBe(
"/if/admin/core/applications?page=2",
);
});

it("translates JSON booleans", () => {
const hash = "#/x;" + encodeURIComponent(JSON.stringify({ enabled: true }));

expect(translateHashRoute(hash, scope)).toBe("/if/admin/x?enabled=true");
});

it("translates JSON arrays into repeated keys", () => {
const hash = "#/x;" + encodeURIComponent(JSON.stringify({ ids: [1, 2] }));

expect(translateHashRoute(hash, scope)).toBe("/if/admin/x?ids=1&ids=2");
});

it("drops empty JSON params", () => {
expect(translateHashRoute("#/core/applications;{}", scope)).toBe(
"/if/admin/core/applications",
);
});

it("translates URLSearchParams-style params", () => {
expect(translateHashRoute("#/core/groups;page=2&search=abc", scope)).toBe(
"/if/admin/core/groups?page=2&search=abc",
);
});

it("translates URLSearchParams booleans", () => {
expect(translateHashRoute("#/x;active=true", scope)).toBe("/if/admin/x?active=true");
});

it("respects a non-root base and interface", () => {
const authScope: HashRouteScope = { base: "/auth/", interfaceName: "user" };

expect(translateHashRoute("#/settings", authScope)).toBe("/auth/if/user/settings");
});
});
80 changes: 80 additions & 0 deletions web/src/elements/router/core/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @file Cross-interface href builders.
*
* The only sanctioned channel for referencing another interface: these return
* full, base-path-aware URL strings for use with plain `<a href>` /
* `location.assign`. Crossing interfaces is a real page load (a different
* bundle). Fixes the hardcoded `/if/user/` literals that break under a
* non-root `web.path`.
*/

import { getRouterConfig } from "#elements/router/core/config";
import { recordToSearchParams, type RouterParameterInit } from "#elements/router/core/parameters";

function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}

function stripLeadingSlash(value: string): string {
return value.replace(/^\/+/, "");
}

/**
* Build the pathname prefix owned by an interface, e.g. `/auth/if/admin/`.
*
* The single source of truth for prefix construction — the href builders,
* click interceptor, and hash shim must all agree byte-for-byte.
*/
export function formatInterfacePrefix(base: string, interfaceName: string): string {
return `${ensureTrailingSlash(base)}if/${interfaceName}/`;
}

function buildSearch(params?: RouterParameterInit): string {
if (!params) return "";

const search = recordToSearchParams(params).toString();

return search ? `?${search}` : "";
}

/**
* Build a full, base-path-aware URL for the given interface.
*
* @param interfaceName The target interface segment, e.g. `admin`.
* @param path The path within the interface, with or without a leading slash.
* @param params Optional search parameters.
*/
export function formatInterfaceURL(
interfaceName: string,
path = "",
params?: RouterParameterInit,
): string {
const { base } = getRouterConfig();
const prefix = formatInterfacePrefix(base, interfaceName);

return `${prefix}${stripLeadingSlash(path)}${buildSearch(params)}`;
}

/**
* Build a URL into the admin interface.
*/
export function toAdminInterface(path?: string, params?: RouterParameterInit): string {
return formatInterfaceURL("admin", path, params);
}

/**
* Build a URL into the user interface.
*/
export function toUserInterface(path?: string, params?: RouterParameterInit): string {
return formatInterfaceURL("user", path, params);
}

/**
* Build a URL into the flow interface for a given flow slug.
*
* The flow interface keeps its server-driven, trailing-slashed URL space
* (`/if/flow/<slug>/`).
*/
export function toFlowInterface(slug: string, params?: RouterParameterInit): string {
return formatInterfaceURL("flow", ensureTrailingSlash(slug), params);
}
Loading
Loading