This commit is contained in:
Teffen Ellis
2025-04-01 05:16:56 +02:00
parent 0e57e06191
commit ff3e59c8e2
11 changed files with 237 additions and 95 deletions

View File

@ -1,14 +1,13 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js"; import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js"; import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js"; import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { isSlug } from "@goauthentik/common/utils.js"; import { isSlug, isURLInput } from "@goauthentik/common/utils.js";
import { camelToSnake } from "@goauthentik/common/utils.js"; import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input"; import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import { type NavigableButton, type WizardButton } from "@goauthentik/components/ak-wizard/types"; import { type NavigableButton, type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
@ -21,13 +20,25 @@ import { type ApplicationRequest } from "@goauthentik/api";
import { ApplicationWizardStateUpdate, ValidationRecord } from "../types"; import { ApplicationWizardStateUpdate, ValidationRecord } from "../types";
const autoTrim = (v: unknown) => (typeof v === "string" ? v.trim() : v); /**
* Plucks the specified keys from an object, trimming their values if they are strings.
*
* @template T - The type of the input object.
* @template K - The keys to be plucked from the input object.
*
* @param {T} input - The input object.
* @param {Array<K>} keys - The keys to be plucked from the input object.
*/
function trimMany<T extends object, K extends keyof T>(input: T, keys: Array<K>): Pick<T, K> {
const result: Partial<T> = {};
const trimMany = (o: KeyUnknown, vs: string[]) => for (const key of keys) {
Object.fromEntries(vs.map((v) => [v, autoTrim(o[v])])); const value = input[key];
result[key] = (typeof value === "string" ? value.trim() : value) as T[K];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any return result as Pick<T, K>;
const isStr = (v: any): v is string => typeof v === "string"; }
@customElement("ak-application-wizard-application-step") @customElement("ak-application-wizard-application-step")
export class ApplicationWizardApplicationStep extends ApplicationWizardStep { export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
@ -54,27 +65,34 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
} }
get buttons(): WizardButton[] { get buttons(): WizardButton[] {
return [{ kind: "next", destination: "provider-choice" }, { kind: "cancel" }]; return [
// ---
{ kind: "next", destination: "provider-choice" },
{ kind: "cancel" },
];
} }
get valid() { get valid() {
this.errors = new Map(); this.errors = new Map();
const values = trimMany(this.formValues ?? {}, ["metaLaunchUrl", "name", "slug"]);
if (values.name === "") { const trimmed = trimMany((this.formValues || {}) as Partial<ApplicationRequest>, [
"name",
"slug",
"metaLaunchUrl",
]);
if (!trimmed.name) {
this.errors.set("name", msg("An application name is required")); this.errors.set("name", msg("An application name is required"));
} }
if (
!( if (!isURLInput(trimmed.metaLaunchUrl)) {
isStr(values.metaLaunchUrl) &&
(values.metaLaunchUrl === "" || URL.canParse(values.metaLaunchUrl))
)
) {
this.errors.set("metaLaunchUrl", msg("Not a valid URL")); this.errors.set("metaLaunchUrl", msg("Not a valid URL"));
} }
if (!(isStr(values.slug) && values.slug !== "" && isSlug(values.slug))) {
if (!isSlug(trimmed.slug)) {
this.errors.set("slug", msg("Not a valid slug")); this.errors.set("slug", msg("Not a valid slug"));
} }
return this.errors.size === 0; return this.errors.size === 0;
} }
@ -82,27 +100,39 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
if (button.kind === "next") { if (button.kind === "next") {
if (!this.valid) { if (!this.valid) {
this.handleEnabling({ this.handleEnabling({
disabled: ["provider-choice", "provider", "bindings", "submit"], disabled: [
// ---
"provider-choice",
"provider",
"bindings",
"submit",
],
}); });
return; return;
} }
const app: Partial<ApplicationRequest> = this.formValues as Partial<ApplicationRequest>; const app: Partial<ApplicationRequest> = this.formValues as Partial<ApplicationRequest>;
let payload: ApplicationWizardStateUpdate = { let payload: ApplicationWizardStateUpdate = {
app: this.formValues, app: this.formValues,
errors: this.removeErrors("app"), errors: this.removeErrors("app"),
}; };
if (app.name && (this.wizard.provider?.name ?? "").trim() === "") { if (app.name && (this.wizard.provider?.name ?? "").trim() === "") {
payload = { payload = {
...payload, ...payload,
provider: { name: `Provider for ${app.name}` }, provider: { name: `Provider for ${app.name}` },
}; };
} }
this.handleUpdate(payload, button.destination, { this.handleUpdate(payload, button.destination, {
enable: "provider-choice", enable: "provider-choice",
}); });
return; return;
} }
super.handleButton(button); super.handleButton(button);
} }
@ -181,6 +211,7 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
if (!(this.wizard.app && this.wizard.errors)) { if (!(this.wizard.app && this.wizard.errors)) {
throw new Error("Application Step received uninitialized wizard context."); throw new Error("Application Step received uninitialized wizard context.");
} }
return this.renderForm( return this.renderForm(
this.wizard.app as ApplicationRequest, this.wizard.app as ApplicationRequest,
this.wizard.errors?.app ?? {}, this.wizard.errors?.app ?? {},

View File

@ -15,7 +15,9 @@ export const bindModeOptions = [
{ {
label: msg("Direct binding"), label: msg("Direct binding"),
value: LDAPAPIAccessMode.Direct, value: LDAPAPIAccessMode.Direct,
description: html`${msg("Always execute the configured bind flow to authenticate the user")}`, description: html`${msg(
"Always execute the configured bind flow to authenticate the user",
)}`,
}, },
]; ];
@ -31,7 +33,9 @@ export const searchModeOptions = [
{ {
label: msg("Direct querying"), label: msg("Direct querying"),
value: LDAPAPIAccessMode.Direct, value: LDAPAPIAccessMode.Direct,
description: html`${msg("Always returns the latest data, but slower than cached querying")}`, description: html`${msg(
"Always returns the latest data, but slower than cached querying",
)}`,
}, },
]; ];

View File

@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger"; export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress"; export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current"; export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.2"; export const VERSION = "2025.2.3";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";"; export const ROUTE_SEPARATOR = ";";

View File

@ -25,10 +25,35 @@ export function convertToSlug(text: string): string {
.replace(/[^\w-]+/g, ""); .replace(/[^\w-]+/g, "");
} }
export function isSlug(text: string): boolean { /**
const lowered = text.toLowerCase(); * Type guard to check if a given string is a valid URL slug, i.e.
const forbidden = /([^\w-]|\s)/.test(lowered); * only containing alphanumeric characters, dashes, and underscores.
return lowered === text && !forbidden; */
export function isSlug(input: unknown): input is string {
if (typeof input !== "string") return false;
if (!input) return false;
const lowered = input.toLowerCase();
if (input !== lowered) return false;
return /([^\w-]|\s)/.test(lowered);
}
/**
* Type guard to check if a given input is parsable as a URL.
*
* ```js
* isURLInput("https://example.com") // true
* isURLInput("invalid-url") // false
* isURLInput(new URL("https://example.com")) // true
* ```
*/
export function isURLInput(input: unknown): input is string | URL {
if (typeof input !== "string" && !(input instanceof URL)) return false;
if (!input) return false;
return URL.canParse(input);
} }
/** /**

View File

@ -11,6 +11,7 @@ import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css"; import PFTable from "@patternfly/patternfly/components/Table/table.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
@ -34,6 +35,9 @@ export class SyncStatusTable extends Table<SystemTask> {
} }
async apiEndpoint(): Promise<PaginatedResponse<SystemTask>> { async apiEndpoint(): Promise<PaginatedResponse<SystemTask>> {
if (this.tasks.length === 1) {
this.expandedElements = this.tasks;
}
return { return {
pagination: { pagination: {
next: 0, next: 0,
@ -104,7 +108,7 @@ export class SyncStatusCard extends AKElement {
triggerSync!: () => Promise<unknown>; triggerSync!: () => Promise<unknown>;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFCard, PFTable]; return [PFBase, PFButton, PFCard, PFTable];
} }
firstUpdated() { firstUpdated() {
@ -133,7 +137,20 @@ export class SyncStatusCard extends AKElement {
render(): TemplateResult { render(): TemplateResult {
return html`<div class="pf-c-card"> return html`<div class="pf-c-card">
<div class="pf-c-card__title">${msg("Sync status")}</div> <div class="pf-c-card__header">
<div class="pf-c-card__actions">
<button
class="pf-c-button pf-m-plain"
type="button"
@click=${() => {
this.fetch();
}}
>
<i class="fa fa-sync"></i>
</button>
</div>
<div class="pf-c-card__title">${msg("Sync status")}</div>
</div>
<div class="pf-c-card__body">${this.renderSyncStatus()}</div> <div class="pf-c-card__body">${this.renderSyncStatus()}</div>
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
<ak-action-button <ak-action-button

View File

@ -187,7 +187,11 @@ export class Wizard extends ModalButton {
/** /**
* Reset the wizard to it's initial state. * Reset the wizard to it's initial state.
*/ */
reset = () => { reset = (ev?: Event) => {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
this.open = false; this.open = false;
this.querySelectorAll("[data-wizardmanaged=true]").forEach((el) => { this.querySelectorAll("[data-wizardmanaged=true]").forEach((el) => {
@ -332,9 +336,7 @@ export class Wizard extends ModalButton {
<button <button
class="pf-c-button pf-m-link" class="pf-c-button pf-m-link"
type="button" type="button"
@click=${() => { @click=${this.reset}
this.reset();
}}
> >
${msg("Cancel")} ${msg("Cancel")}
</button> </button>

View File

@ -72,7 +72,9 @@ export class BaseStage<
} }
return this.host?.submit(object as unknown as Tout).then((successful) => { return this.host?.submit(object as unknown as Tout).then((successful) => {
if (successful) { if (successful) {
this.cleanup(); this.onSubmitSuccess();
} else {
this.onSubmitFailure();
} }
return successful; return successful;
}); });
@ -80,13 +82,13 @@ export class BaseStage<
renderNonFieldErrors() { renderNonFieldErrors() {
const errors = this.challenge?.responseErrors || {}; const errors = this.challenge?.responseErrors || {};
if (!("non_field_errors" in errors)) {
return nothing; if (!("non_field_errors" in errors)) return nothing;
}
const nonFieldErrors = errors.non_field_errors; const nonFieldErrors = errors.non_field_errors;
if (!nonFieldErrors) {
return nothing; if (!nonFieldErrors) return nothing;
}
return html`<div class="pf-c-form__alert"> return html`<div class="pf-c-form__alert">
${nonFieldErrors.map((err) => { ${nonFieldErrors.map((err) => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger"> return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
@ -124,7 +126,12 @@ export class BaseStage<
`; `;
} }
cleanup(): void { onSubmitSuccess(): void {
// Method that can be overridden by stages
return;
}
onSubmitFailure(): void {
// Method that can be overridden by stages // Method that can be overridden by stages
return; return;
} }

View File

@ -1,4 +1,4 @@
/// <reference types="@hcaptcha/types"/> ///<reference types="@hcaptcha/types"/>
import { renderStatic } from "@goauthentik/common/purify"; import { renderStatic } from "@goauthentik/common/purify";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import { akEmptyState } from "@goauthentik/elements/EmptyState"; import { akEmptyState } from "@goauthentik/elements/EmptyState";
@ -9,7 +9,7 @@ import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic"; import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base"; import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern"; import { P, match } from "ts-pattern";
import type { TurnstileObject } from "turnstile-types"; import type * as _ from "turnstile-types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -24,10 +24,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api"; import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
interface TurnstileWindow extends Window {
turnstile: TurnstileObject;
}
type TokenHandler = (token: string) => void; type TokenHandler = (token: string) => void;
type Dims = { height: number }; type Dims = { height: number };
@ -52,6 +48,8 @@ type CaptchaHandler = {
name: string; name: string;
interactive: () => Promise<unknown>; interactive: () => Promise<unknown>;
execute: () => Promise<unknown>; execute: () => Promise<unknown>;
refreshInteractive: () => Promise<unknown>;
refresh: () => Promise<unknown>;
}; };
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces // A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
@ -119,6 +117,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
this.host.submit({ component: "ak-stage-captcha", token }); this.host.submit({ component: "ak-stage-captcha", token });
}; };
@property({ attribute: false })
refreshedAt = new Date();
@state()
activeHandler?: CaptchaHandler = undefined;
@state() @state()
error?: string; error?: string;
@ -127,16 +131,22 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
name: "grecaptcha", name: "grecaptcha",
interactive: this.renderGReCaptchaFrame, interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha, execute: this.executeGReCaptcha,
refreshInteractive: this.refreshGReCaptchaFrame,
refresh: this.refreshGReCaptcha,
}, },
{ {
name: "hcaptcha", name: "hcaptcha",
interactive: this.renderHCaptchaFrame, interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha, execute: this.executeHCaptcha,
refreshInteractive: this.refreshHCaptchaFrame,
refresh: this.refreshHCaptcha,
}, },
{ {
name: "turnstile", name: "turnstile",
interactive: this.renderTurnstileFrame, interactive: this.renderTurnstileFrame,
execute: this.executeTurnstile, execute: this.executeTurnstile,
refreshInteractive: this.refreshTurnstileFrame,
refresh: this.refreshTurnstile,
}, },
]; ];
@ -205,7 +215,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
console.debug(`authentik/stages/captcha: Unknown message: ${message}`); console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
}, },
) )
.otherwise(() => null); .otherwise(() => {});
} }
async renderGReCaptchaFrame() { async renderGReCaptchaFrame() {
@ -230,6 +240,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}); });
} }
async refreshGReCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.grecaptcha.reset();
}
async refreshGReCaptcha() {
window.grecaptcha.reset();
window.grecaptcha.execute();
}
async renderHCaptchaFrame() { async renderHCaptchaFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
@ -251,6 +270,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
); );
} }
async refreshHCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.hcaptcha.reset();
}
async refreshHCaptcha() {
window.hcaptcha.reset();
window.hcaptcha.execute();
}
async renderTurnstileFrame() { async renderTurnstileFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
@ -262,13 +290,18 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
} }
async executeTurnstile() { async executeTurnstile() {
return (window as unknown as TurnstileWindow).turnstile.render( return window.turnstile.render(this.captchaDocumentContainer, {
this.captchaDocumentContainer, sitekey: this.challenge.siteKey,
{ callback: this.onTokenChange,
sitekey: this.challenge.siteKey, });
callback: this.onTokenChange, }
},
); async refreshTurnstileFrame() {
(this.captchaFrame.contentWindow as typeof window)?.turnstile.reset();
}
async refreshTurnstile() {
window.turnstile.reset();
} }
async renderFrame(captchaElement: TemplateResult) { async renderFrame(captchaElement: TemplateResult) {
@ -336,16 +369,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name)); const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
let lastError = undefined; let lastError = undefined;
let found = false; let found = false;
for (const { name, interactive, execute } of handlers) { for (const handler of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${name}`); console.debug(`authentik/stages/captcha: trying handler ${handler.name}`);
try { try {
const runner = this.challenge.interactive ? interactive : execute; const runner = this.challenge.interactive
? handler.interactive
: handler.execute;
await runner.apply(this); await runner.apply(this);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`); console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`);
found = true; found = true;
this.activeHandler = handler;
break; break;
} catch (exc) { } catch (exc) {
console.debug(`authentik/stages/captcha[${name}]: handler failed`); console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`);
console.debug(exc); console.debug(exc);
lastError = exc; lastError = exc;
} }
@ -370,6 +406,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
document.body.appendChild(this.captchaDocumentContainer); document.body.appendChild(this.captchaDocumentContainer);
} }
} }
updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("refreshedAt") || !this.challenge) {
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
if (this.challenge.interactive) {
this.activeHandler?.refreshInteractive.apply(this);
} else {
this.activeHandler?.refresh.apply(this);
}
}
} }
declare global { declare global {

View File

@ -49,6 +49,8 @@ export class IdentificationStage extends BaseStage<
@state() @state()
captchaToken = ""; captchaToken = "";
@state()
captchaRefreshedAt = new Date();
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
@ -136,6 +138,7 @@ export class IdentificationStage extends BaseStage<
if (ev.key === "Enter") { if (ev.key === "Enter") {
this.submitForm(ev); this.submitForm(ev);
} }
const el = ev.target as HTMLInputElement; const el = ev.target as HTMLInputElement;
// Because the password field is not actually on this page, // Because the password field is not actually on this page,
// and we want to 'prefill' the password for the user, // and we want to 'prefill' the password for the user,
@ -179,12 +182,16 @@ export class IdentificationStage extends BaseStage<
this.form.appendChild(totp); this.form.appendChild(totp);
} }
cleanup(): void { onSubmitSuccess(): void {
if (this.form) { if (this.form) {
this.form.remove(); this.form.remove();
} }
} }
onSubmitFailure(): void {
this.captchaRefreshedAt = new Date();
}
renderSource(source: LoginSource): TemplateResult { renderSource(source: LoginSource): TemplateResult {
const icon = renderSourceIcon(source.name, source.iconUrl); const icon = renderSourceIcon(source.name, source.iconUrl);
return html`<li class="pf-c-login__main-footer-links-item"> return html`<li class="pf-c-login__main-footer-links-item">
@ -287,6 +294,7 @@ export class IdentificationStage extends BaseStage<
.onTokenChange=${(token: string) => { .onTokenChange=${(token: string) => {
this.captchaToken = token; this.captchaToken = token;
}} }}
.refreshedAt=${this.captchaRefreshedAt}
embedded embedded
></ak-stage-captcha> ></ak-stage-captcha>
` `

View File

@ -11,41 +11,41 @@ export const sourceLocale = `en`;
* lexicographically. * lexicographically.
*/ */
export const targetLocales = [ export const targetLocales = [
`de`, `de`,
`en`, `en`,
`es`, `es`,
`fr`, `fr`,
`it`, `it`,
`ko`, `ko`,
`nl`, `nl`,
`pl`, `pl`,
`pseudo-LOCALE`, `pseudo-LOCALE`,
`ru`, `ru`,
`tr`, `tr`,
`zh_TW`, `zh_TW`,
`zh-CN`, `zh-CN`,
`zh-Hans`, `zh-Hans`,
`zh-Hant`, `zh-Hant`,
] as const; ] as const;
/** /**
* All valid project locale codes. Sorted lexicographically. * All valid project locale codes. Sorted lexicographically.
*/ */
export const allLocales = [ export const allLocales = [
`de`, `de`,
`en`, `en`,
`en`, `en`,
`es`, `es`,
`fr`, `fr`,
`it`, `it`,
`ko`, `ko`,
`nl`, `nl`,
`pl`, `pl`,
`pseudo-LOCALE`, `pseudo-LOCALE`,
`ru`, `ru`,
`tr`, `tr`,
`zh_TW`, `zh_TW`,
`zh-CN`, `zh-CN`,
`zh-Hans`, `zh-Hans`,
`zh-Hant`, `zh-Hant`,
] as const; ] as const;

View File

@ -1,8 +1,7 @@
import "@formatjs/intl-listformat/locale-data/en";
import "@formatjs/intl-listformat/polyfill";
import "@webcomponents/webcomponentsjs";
import "construct-style-sheets-polyfill"; import "construct-style-sheets-polyfill";
import "@webcomponents/webcomponentsjs";
import "lit/polyfill-support.js";
import "core-js/actual"; import "core-js/actual";
import "lit/polyfill-support.js"; import "@formatjs/intl-listformat/polyfill";
import "@formatjs/intl-listformat/locale-data/en";