Repository: goauthentik/authentik
Pull request: web: fix locale prioritization scheme
web: fix locale prioritization scheme

## Details

The locale priority algorithm had two problems: first, the order was incorrect, allowing the global default from globalAK() to override a lot of more precise settings; second, the algorithm would take outside locale overrides from the event handler, which was not necessary.

This commit revises the locale prioritization scheme. It continues to watch for “change of locale” events from all sources (URL, browser, and user/brand/site internal settings), but if the event carries a suggested locale, that suggestion is ignored. Instead, when a change of locale event occurs, it re-runs the algorithm in priority order.

That order is:

- The URL query parameter `locale=`
- The User’s stated preference in `CurrentUser.attributes`
- The Browser’s stated locale
- The Brand’s stated preference in `CurrentBrand.attributes`
- The authentik instance’s setting `from window.globalAK()`
- The default locale complied into the UI at build time.

Note to @tanberry: We should note this order somewhere in the documentation, so that users are not “surprised” that their user preference (set in User Interface ⇒ Settings ⇒ User Details ⇒ Locale) is not overriden by the browser’s preference. (The setting they need is “Based on your browser” to make browser locale detection work.)

closes #4562

## Checklist

-   [X] The code has been formatted (`make web`)


Problem statement:
Locale Auto-detect doesn't work
**Describe the bug**
Using a browser configured in French, but Authentik is in English when locale is "Auto-detect".

**To Reproduce**
Steps to reproduce the behavior:
1. Got to Authentik with a French browser
2. View Authentik UI in English

**Expected behavior**
Authentik UI in French.

**Logs**
Logs of Firefox and Chrome debug console
`authentik(early): version 2023.1.2, apiBase /api/v3 [config.ts:105:8]
authentik/locale: No matching locale for en-us [locale.ts:147:20]
authentik/locale: Activating detected locale 'en' [locale.ts:143:20]`

**Version and Deployment (please complete the following information):**
 - 2023.1.2
 - docker compose

**Additional context**
Tested with multiple browser (Chrome Desktop 109.0.5414.120, Chrome Android 109.0.5414.117, Firefox 109.0)

Hints from issue discussion:
Can you run `navigator.language || navigator.userLanguage` in the browser's developer console and post the output?
In Firefox:
"fr"
In Chrome: 
'fr-FR'
same problem for me with 2023.2

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Problem still present in 2023.4.1
Not sure if it's the same issue, but it don't work in 2023.5.3.
My preferred languages in Firefox and Chromium are (in priority order) :
- fr-ca
- fr
- en-ca
- en

But pages shows in english.

Also, in the user settings, the list of available languages appears oddly :
![image](https://github.com/goauthentik/authentik/assets/98410832/7e94796a-ba54-46e9-977c-84c98756fd33)

Edit :
I found that I can force a language globally by adding this in the Attributes (in Other global settings) of my Tenant :
```
settings:
  locale: fr
```
I'd like to report the same with Spanish and version 2023.6.0
I've tried Firefox and Chromium and both 'es' and 'es-ES'.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
Not resolved in 2023.8.3
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Commit message: web: fix locale prioritization scheme

The locale priority algorithm had two problems: first, the order was incorrect, allowing the global
default from globalAK() to override a lot of more precise settings; second, the algorithm would take
outside locale overrides from the event handler, which was not necessary.

This commit revises the locale prioritization scheme.  It continues to watch for "change of locale"
events from all sources (URL, browser, and user/brand/site internal settings), but if the event
carries a suggested locale, that suggestion is ignored.  Instead, when a change of locale event
occurs, it re-runs the algorithm in priority order.

That order is:

- The URL query parameter `locale=`
- The User's stated preference in `CurrentUser.attributes`
- The Browser's stated locale
- The Brand's stated preference in `CurrentBrand.attributes`
- The authentik instance's setting `from window.globalAK()`
- The default locale complied into the UI at build time.

Note to @tanberry: We should note this order somewhere in the documentation, so that users are not
"surprised" that their user preference (set in User Interface -> Settings -> User Details -> Locale)
is not overriden by the browser's preference.  (The setting they need is "Based on your browser" to
make browser locale detection work.)

Patch to review:
diff --git a/web/src/elements/ak-locale-context/ak-locale-context.ts b/web/src/elements/ak-locale-context/ak-locale-context.ts
index 810b001999e2..0539c3e9029d 100644
--- a/web/src/elements/ak-locale-context/ak-locale-context.ts
+++ b/web/src/elements/ak-locale-context/ak-locale-context.ts
@@ -1,18 +1,19 @@
+import { DEFAULT_CONFIG } from "@goauthentik/authentik/common/api/config";
 import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
 import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
-import { customEvent, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
+import { customEvent } from "@goauthentik/elements/utils/customEvents";
 
 import { LitElement, html } from "lit";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
 
+import { CoreApi } from "@goauthentik/api";
+
+import { WithBrandConfig } from "../Interface/brandProvider";
 import { initializeLocalization } from "./configureLocale";
 import type { LocaleGetter, LocaleSetter } from "./configureLocale";
-import {
-    DEFAULT_LOCALE,
-    autoDetectLanguage,
-    getBestMatchLocale,
-    localeCodeFromUrl,
-} from "./helpers";
+import { DEFAULT_LOCALE, autoDetectLanguage, getBestMatchLocale } from "./helpers";
+
+const LocaleContextBase = WithBrandConfig(LitElement);
 
 /**
  * A component to manage your locale settings.
@@ -28,7 +29,7 @@ import {
  * @fires ak-locale-change - When a valid locale has been swapped in
  */
 @customElement("ak-locale-context")
-export class LocaleContext extends LitElement {
+export class LocaleContext extends LocaleContextBase {
     /// @attribute The text representation of the current locale */
     @property({ attribute: true, type: String })
     locale = DEFAULT_LOCALE;
@@ -41,6 +42,9 @@ export class LocaleContext extends LitElement {
 
     setLocale: LocaleSetter;
 
+    @state()
+    userLocale = "";
+
     constructor(code = DEFAULT_LOCALE) {
         super();
         this.notifyApplication = this.notifyApplication.bind(this);
@@ -59,8 +63,11 @@ export class LocaleContext extends LitElement {
 
     connectedCallback() {
         super.connectedCallback();
-        const localeRequest = autoDetectLanguage(this.locale);
-        this.updateLocale(localeRequest);
+        new CoreApi(DEFAULT_CONFIG)
+            .coreUsersMeRetrieve()
+            .then((user) => (this.userLocale = user?.user?.settings?.locale ?? ""))
+            .catch(() => {});
+        this.updateLocale();
         window.addEventListener(EVENT_LOCALE_REQUEST, this.updateLocaleHandler);
     }
 
@@ -69,28 +76,19 @@ export class LocaleContext extends LitElement {
         super.disconnectedCallback();
     }
 
-    updateLocaleHandler(ev: Event) {
-        if (!isCustomEvent(ev)) {
-            console.warn(`Received a non-custom event at EVENT_LOCALE_REQUEST: ${ev}`);
-            return;
-        }
+    updateLocaleHandler(_ev: Event) {
         console.debug("authentik/locale: Locale update request received.");
-        this.updateLocale(ev.detail.locale);
+        this.updateLocale();
     }
 
-    updateLocale(code: string) {
-        const urlCode = localeCodeFromUrl(this.param);
-        const requestedLocale = urlCode ? urlCode : code;
-        const locale = getBestMatchLocale(requestedLocale);
+    updateLocale() {
+        const localeRequest = autoDetectLanguage(this.userLocale, this.brand?.defaultLocale);
+        const locale = getBestMatchLocale(localeRequest);
         if (!locale) {
-            console.warn(`authentik/locale: failed to find locale for code ${code}`);
+            console.warn(`authentik/locale: failed to find locale for code ${localeRequest}`);
             return;
         }
         locale.locale().then(() => {
-            console.debug(`authentik/locale: Loaded locale '${code}'`);
-            if (this.getLocale() === requestedLocale) {
-                return;
-            }
             console.debug(`Setting Locale to ... ${locale.label()} (${locale.code})`);
             this.setLocale(locale.code).then(() => {
                 window.setTimeout(this.notifyApplication, 0);
diff --git a/web/src/elements/ak-locale-context/helpers.ts b/web/src/elements/ak-locale-context/helpers.ts
index 51ef76509572..751371485a8c 100644
--- a/web/src/elements/ak-locale-context/helpers.ts
+++ b/web/src/elements/ak-locale-context/helpers.ts
@@ -45,12 +45,13 @@ export function localeCodeFromUrl(param = "locale") {
 const isLocaleCandidate = (v: unknown): v is string =>
     typeof v === "string" && v !== "" && v !== TOMBSTONE;
 
-export function autoDetectLanguage(requestedCode?: string): string {
+export function autoDetectLanguage(userReq = TOMBSTONE, brandReq = TOMBSTONE): string {
     const localeCandidates: string[] = [
-        globalAK()?.locale ?? TOMBSTONE,
         localeCodeFromUrl("locale"),
-        requestedCode ?? TOMBSTONE,
+        userReq,
         window.navigator?.language ?? TOMBSTONE,
+        brandReq,
+        globalAK()?.locale ?? TOMBSTONE,
         DEFAULT_LOCALE,
     ].filter(isLocaleCandidate);
 


Code review comments:
diff_hunk: @@ -59,8 +63,11 @@ export class LocaleContext extends LitElement {
 
     connectedCallback() {
         super.connectedCallback();
-        const localeRequest = autoDetectLanguage(this.locale);
-        this.updateLocale(localeRequest);
+        new CoreApi(DEFAULT_CONFIG)
+            .coreUsersMeRetrieve()
+            .then((user) => (this.userLocale = user?.user?.settings?.locale ?? ""))
+            .catch(() => {});
path: web/src/elements/ak-locale-context/ak-locale-context.ts
text: @author:
Can't use `me()` here, it'll cause an infinite loop since it'll 403 when not logged in and *force* a redirect to the login page.  Which will then loop.  Doing it by hand allows us to avoid that.  We get the `EVENT_REFRESH` event anyway when we transition from the Flow to the Admin or User interfaces.

@user1:
Overall I think it would probably be a better pattern to not have `me()` do a redirect and just add a mixin that redirects to login if not authenticated, but that can be a separate PR