web: re-organise frontend and cleanup common code (#3572)
* fix repo in api client Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: re-organise files to match their interface Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * core: include version in script tags Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * cleanup maybe broken Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * revert rename Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: get rid of Client.ts Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * move more to common Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * more moving Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * format Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * unfuck files that vscode fucked, thanks Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * move more Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * finish moving (maybe) Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * ok more moving Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * fix more stuff that vs code destroyed Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * get rid "web" prefix for virtual package Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * fix locales Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * use custom base element Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * fix css file Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * don't run autoDetectLanguage when importing locale Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * fix circular dependencies Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * web: fix build Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
105
web/src/common/api/config.ts
Normal file
105
web/src/common/api/config.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import {
|
||||
CSRFMiddleware,
|
||||
EventMiddleware,
|
||||
LoggingMiddleware,
|
||||
} from "@goauthentik/common/api/middleware";
|
||||
import { EVENT_REFRESH, VERSION } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { activateLocale } from "@goauthentik/common/ui/locale";
|
||||
|
||||
import {
|
||||
Config,
|
||||
ConfigFromJSON,
|
||||
Configuration,
|
||||
CoreApi,
|
||||
CurrentTenant,
|
||||
CurrentTenantFromJSON,
|
||||
RootApi,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
let globalConfigPromise: Promise<Config> | undefined = Promise.resolve(
|
||||
ConfigFromJSON(globalAK()?.config),
|
||||
);
|
||||
export function config(): Promise<Config> {
|
||||
if (!globalConfigPromise) {
|
||||
globalConfigPromise = new RootApi(DEFAULT_CONFIG).rootConfigRetrieve();
|
||||
}
|
||||
return globalConfigPromise;
|
||||
}
|
||||
|
||||
export function tenantSetFavicon(tenant: CurrentTenant) {
|
||||
/**
|
||||
* <link rel="icon" href="/static/dist/assets/icons/icon.png">
|
||||
* <link rel="shortcut icon" href="/static/dist/assets/icons/icon.png">
|
||||
*/
|
||||
const rels = ["icon", "shortcut icon"];
|
||||
rels.forEach((rel) => {
|
||||
let relIcon = document.head.querySelector<HTMLLinkElement>(`link[rel='${rel}']`);
|
||||
if (!relIcon) {
|
||||
relIcon = document.createElement("link");
|
||||
relIcon.rel = rel;
|
||||
document.getElementsByTagName("head")[0].appendChild(relIcon);
|
||||
}
|
||||
relIcon.href = tenant.brandingFavicon;
|
||||
});
|
||||
}
|
||||
|
||||
export function tenantSetLocale(tenant: CurrentTenant) {
|
||||
if (tenant.defaultLocale === "") {
|
||||
return;
|
||||
}
|
||||
console.debug("authentik/locale: setting locale from tenant default");
|
||||
activateLocale(tenant.defaultLocale);
|
||||
}
|
||||
|
||||
let globalTenantPromise: Promise<CurrentTenant> | undefined = Promise.resolve(
|
||||
CurrentTenantFromJSON(globalAK()?.tenant),
|
||||
);
|
||||
export function tenant(): Promise<CurrentTenant> {
|
||||
if (!globalTenantPromise) {
|
||||
globalTenantPromise = new CoreApi(DEFAULT_CONFIG)
|
||||
.coreTenantsCurrentRetrieve()
|
||||
.then((tenant) => {
|
||||
tenantSetFavicon(tenant);
|
||||
tenantSetLocale(tenant);
|
||||
return tenant;
|
||||
});
|
||||
}
|
||||
return globalTenantPromise;
|
||||
}
|
||||
|
||||
export function getMetaContent(key: string): string {
|
||||
const metaEl = document.querySelector<HTMLMetaElement>(`meta[name=${key}]`);
|
||||
if (!metaEl) return "";
|
||||
return metaEl.content;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = new Configuration({
|
||||
basePath: process.env.AK_API_BASE_PATH + "/api/v3",
|
||||
headers: {
|
||||
"sentry-trace": getMetaContent("sentry-trace"),
|
||||
},
|
||||
middleware: [
|
||||
new CSRFMiddleware(),
|
||||
new EventMiddleware(),
|
||||
new LoggingMiddleware(CurrentTenantFromJSON(globalAK()?.tenant)),
|
||||
],
|
||||
});
|
||||
|
||||
// This is just a function so eslint doesn't complain about
|
||||
// missing-whitespace-between-attributes or
|
||||
// unexpected-character-in-attribute-name
|
||||
export function AndNext(url: string): string {
|
||||
return `?next=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
window.addEventListener(EVENT_REFRESH, () => {
|
||||
// Upon global refresh, disregard whatever was pre-hydrated and
|
||||
// actually load info from API
|
||||
globalConfigPromise = undefined;
|
||||
globalTenantPromise = undefined;
|
||||
config();
|
||||
tenant();
|
||||
});
|
||||
|
||||
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);
|
56
web/src/common/api/middleware.ts
Normal file
56
web/src/common/api/middleware.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
|
||||
import { getCookie } from "@goauthentik/common/utils";
|
||||
|
||||
import {
|
||||
CurrentTenant,
|
||||
FetchParams,
|
||||
Middleware,
|
||||
RequestContext,
|
||||
ResponseContext,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
export interface RequestInfo {
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export class LoggingMiddleware implements Middleware {
|
||||
tenant: CurrentTenant;
|
||||
constructor(tenant: CurrentTenant) {
|
||||
this.tenant = tenant;
|
||||
}
|
||||
|
||||
post(context: ResponseContext): Promise<Response | void> {
|
||||
let msg = `authentik/api[${this.tenant.matchedDomain}]: `;
|
||||
msg += `${context.response.status} ${context.init.method} ${context.url}`;
|
||||
console.debug(msg);
|
||||
return Promise.resolve(context.response);
|
||||
}
|
||||
}
|
||||
|
||||
export class CSRFMiddleware implements Middleware {
|
||||
pre?(context: RequestContext): Promise<FetchParams | void> {
|
||||
// @ts-ignore
|
||||
context.init.headers["X-authentik-CSRF"] = getCookie("authentik_csrf");
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
}
|
||||
|
||||
export class EventMiddleware implements Middleware {
|
||||
post?(context: ResponseContext): Promise<Response | void> {
|
||||
const request: RequestInfo = {
|
||||
method: (context.init.method || "GET").toUpperCase(),
|
||||
path: context.url,
|
||||
status: context.response.status,
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EVENT_REQUEST_POST, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: request,
|
||||
}),
|
||||
);
|
||||
return Promise.resolve(context.response);
|
||||
}
|
||||
}
|
21
web/src/common/constants.ts
Normal file
21
web/src/common/constants.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export const SECONDARY_CLASS = "pf-m-secondary";
|
||||
export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2022.8.2";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
export const EVENT_REFRESH = "ak-refresh";
|
||||
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
|
||||
export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle";
|
||||
export const EVENT_SIDEBAR_TOGGLE = "ak-sidebar-toggle";
|
||||
export const EVENT_WS_MESSAGE = "ak-ws-message";
|
||||
export const EVENT_FLOW_ADVANCE = "ak-flow-advance";
|
||||
export const EVENT_LOCALE_CHANGE = "ak-locale-change";
|
||||
export const EVENT_REQUEST_POST = "ak-request-post";
|
||||
export const EVENT_MESSAGE = "ak-message";
|
||||
|
||||
export const WS_MSG_TYPE_MESSAGE = "message";
|
||||
export const WS_MSG_TYPE_REFRESH = "refresh";
|
@ -1 +1,3 @@
|
||||
export class SentryIgnoredError extends Error {}
|
||||
export class NotFoundError extends Error {}
|
||||
export class RequestError extends Error {}
|
||||
|
29
web/src/common/events.ts
Normal file
29
web/src/common/events.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Event } from "@goauthentik/api";
|
||||
|
||||
export interface EventUser {
|
||||
pk: number;
|
||||
email?: string;
|
||||
username: string;
|
||||
on_behalf_of?: EventUser;
|
||||
}
|
||||
|
||||
export interface EventContext {
|
||||
[key: string]: EventContext | EventModel | string | number | string[];
|
||||
}
|
||||
|
||||
export interface EventWithContext extends Event {
|
||||
user: EventUser;
|
||||
context: EventContext;
|
||||
}
|
||||
|
||||
export interface EventModel {
|
||||
pk: string;
|
||||
name: string;
|
||||
app: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export interface EventRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
18
web/src/common/global.ts
Normal file
18
web/src/common/global.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Config, CurrentTenant } from "@goauthentik/api";
|
||||
|
||||
export interface GlobalAuthentik {
|
||||
locale?: string;
|
||||
flow?: {
|
||||
layout: string;
|
||||
};
|
||||
config: Config;
|
||||
tenant: CurrentTenant;
|
||||
}
|
||||
|
||||
export interface AuthentikWindow {
|
||||
authentik?: GlobalAuthentik;
|
||||
}
|
||||
|
||||
export function globalAK(): GlobalAuthentik | undefined {
|
||||
return (window as unknown as AuthentikWindow).authentik;
|
||||
}
|
117
web/src/common/helpers/plex.ts
Normal file
117
web/src/common/helpers/plex.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
|
||||
export interface PlexPinResponse {
|
||||
// Only has the fields we care about
|
||||
authToken?: string;
|
||||
code: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface PlexResource {
|
||||
name: string;
|
||||
provides: string;
|
||||
clientIdentifier: string;
|
||||
owned: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HEADERS = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Plex-Product": "authentik",
|
||||
"X-Plex-Version": VERSION,
|
||||
"X-Plex-Device-Vendor": "goauthentik.io",
|
||||
};
|
||||
|
||||
export function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null {
|
||||
const top = (screen.height - h) / 4,
|
||||
left = (screen.width - w) / 2;
|
||||
const popup = window.open(
|
||||
url,
|
||||
title,
|
||||
`scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`,
|
||||
);
|
||||
return popup;
|
||||
}
|
||||
|
||||
export class PlexAPIClient {
|
||||
token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
static async getPin(
|
||||
clientIdentifier: string,
|
||||
): Promise<{ authUrl: string; pin: PlexPinResponse }> {
|
||||
const headers = {
|
||||
...DEFAULT_HEADERS,
|
||||
...{
|
||||
"X-Plex-Client-Identifier": clientIdentifier,
|
||||
},
|
||||
};
|
||||
const pinResponse = await fetch("https://plex.tv/api/v2/pins.json?strong=true", {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
});
|
||||
const pin: PlexPinResponse = await pinResponse.json();
|
||||
return {
|
||||
authUrl: `https://app.plex.tv/auth#!?clientID=${encodeURIComponent(
|
||||
clientIdentifier,
|
||||
)}&code=${pin.code}`,
|
||||
pin: pin,
|
||||
};
|
||||
}
|
||||
|
||||
static async pinStatus(clientIdentifier: string, id: number): Promise<string | undefined> {
|
||||
const headers = {
|
||||
...DEFAULT_HEADERS,
|
||||
...{
|
||||
"X-Plex-Client-Identifier": clientIdentifier,
|
||||
},
|
||||
};
|
||||
const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, {
|
||||
headers: headers,
|
||||
});
|
||||
if (pinResponse.status > 200) {
|
||||
throw new SentryIgnoredError("Invalid response code");
|
||||
}
|
||||
const pin: PlexPinResponse = await pinResponse.json();
|
||||
console.debug("authentik/plex: polling Pin");
|
||||
return pin.authToken;
|
||||
}
|
||||
|
||||
static async pinPoll(clientIdentifier: string, id: number): Promise<string> {
|
||||
const executePoll = async (
|
||||
resolve: (authToken: string) => void,
|
||||
reject: (e: Error) => void,
|
||||
) => {
|
||||
try {
|
||||
const response = await PlexAPIClient.pinStatus(clientIdentifier, id);
|
||||
|
||||
if (response) {
|
||||
resolve(response);
|
||||
} else {
|
||||
setTimeout(executePoll, 500, resolve, reject);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e as Error);
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise(executePoll);
|
||||
}
|
||||
|
||||
async getServers(): Promise<PlexResource[]> {
|
||||
const resourcesResponse = await fetch(
|
||||
`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`,
|
||||
{
|
||||
headers: DEFAULT_HEADERS,
|
||||
},
|
||||
);
|
||||
const resources: PlexResource[] = await resourcesResponse.json();
|
||||
return resources.filter((r) => {
|
||||
return r.provides.toLowerCase().includes("server") && r.owned;
|
||||
});
|
||||
}
|
||||
}
|
135
web/src/common/helpers/webauthn.ts
Normal file
135
web/src/common/helpers/webauthn.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import * as base64js from "base64-js";
|
||||
|
||||
export function b64enc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
export function b64RawEnc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
export function u8arr(input: string): Uint8Array {
|
||||
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms items in the credentialCreateOptions generated on the server
|
||||
* into byte arrays expected by the navigator.credentials.create() call
|
||||
*/
|
||||
export function transformCredentialCreateOptions(
|
||||
credentialCreateOptions: PublicKeyCredentialCreationOptions,
|
||||
userId: string,
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const user = credentialCreateOptions.user;
|
||||
// Because json can't contain raw bytes, the server base64-encodes the User ID
|
||||
// So to get the base64 encoded byte array, we first need to convert it to a regular
|
||||
// string, then a byte array, re-encode it and wrap that in an array.
|
||||
const stringId = decodeURIComponent(escape(window.atob(userId)));
|
||||
user.id = u8arr(b64enc(u8arr(stringId)));
|
||||
const challenge = u8arr(credentialCreateOptions.challenge.toString());
|
||||
|
||||
const transformedCredentialCreateOptions = Object.assign({}, credentialCreateOptions, {
|
||||
challenge,
|
||||
user,
|
||||
});
|
||||
|
||||
return transformedCredentialCreateOptions;
|
||||
}
|
||||
|
||||
export interface Assertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
registrationClientExtensions: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the binary data in the credential into base64 strings
|
||||
* for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
export function transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
|
||||
const attObj = new Uint8Array(
|
||||
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
|
||||
);
|
||||
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
|
||||
const rawId = new Uint8Array(newAssertion.rawId);
|
||||
|
||||
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
|
||||
response: {
|
||||
clientDataJSON: b64enc(clientDataJSON),
|
||||
attestationObject: b64enc(attObj),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformCredentialRequestOptions(
|
||||
credentialRequestOptions: PublicKeyCredentialRequestOptions,
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const challenge = u8arr(credentialRequestOptions.challenge.toString());
|
||||
|
||||
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
|
||||
(credentialDescriptor) => {
|
||||
const id = u8arr(credentialDescriptor.id.toString());
|
||||
return Object.assign({}, credentialDescriptor, { id });
|
||||
},
|
||||
);
|
||||
|
||||
const transformedCredentialRequestOptions = Object.assign({}, credentialRequestOptions, {
|
||||
challenge,
|
||||
allowCredentials,
|
||||
});
|
||||
|
||||
return transformedCredentialRequestOptions;
|
||||
}
|
||||
|
||||
export interface AuthAssertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
assertionClientExtensions: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
authenticatorData: string;
|
||||
signature: string;
|
||||
userHandle: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the binary data in the assertion into strings for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
export function transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
|
||||
const response = newAssertion.response as AuthenticatorAssertionResponse;
|
||||
const authData = new Uint8Array(response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
||||
const rawId = new Uint8Array(newAssertion.rawId);
|
||||
const sig = new Uint8Array(response.signature);
|
||||
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
||||
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
|
||||
|
||||
response: {
|
||||
clientDataJSON: b64RawEnc(clientDataJSON),
|
||||
signature: b64RawEnc(sig),
|
||||
authenticatorData: b64RawEnc(authData),
|
||||
userHandle: null,
|
||||
},
|
||||
};
|
||||
}
|
6
web/src/common/messages.ts
Normal file
6
web/src/common/messages.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum MessageLevel {
|
||||
error = "error",
|
||||
warning = "warning",
|
||||
success = "success",
|
||||
info = "info",
|
||||
}
|
83
web/src/common/sentry.ts
Normal file
83
web/src/common/sentry.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { config } from "@goauthentik/common/api/config";
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
|
||||
import { Config, ResponseError } from "@goauthentik/api";
|
||||
|
||||
export const TAG_SENTRY_COMPONENT = "authentik.component";
|
||||
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
|
||||
|
||||
export async function configureSentry(canDoPpi = false): Promise<Config> {
|
||||
const cfg = await config();
|
||||
if (cfg.errorReporting.enabled) {
|
||||
Sentry.init({
|
||||
dsn: "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8",
|
||||
ignoreErrors: [
|
||||
/network/gi,
|
||||
/fetch/gi,
|
||||
/module/gi,
|
||||
// Error on edge on ios,
|
||||
// https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight
|
||||
/instantSearchSDKJSBridgeClearHighlight/gi,
|
||||
// Seems to be an issue in Safari and Firefox
|
||||
/MutationObserver.observe/gi,
|
||||
],
|
||||
release: `authentik@${VERSION}`,
|
||||
tunnel: "/api/v3/sentry/",
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
tracingOrigins: [window.location.host, "localhost"],
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: cfg.errorReporting.tracesSampleRate,
|
||||
environment: cfg.errorReporting.environment,
|
||||
beforeSend: async (
|
||||
event: Sentry.Event,
|
||||
hint: Sentry.EventHint | undefined,
|
||||
): Promise<Sentry.Event | null> => {
|
||||
if (!hint) {
|
||||
return event;
|
||||
}
|
||||
if (hint.originalException instanceof SentryIgnoredError) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
hint.originalException instanceof ResponseError ||
|
||||
hint.originalException instanceof DOMException
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
Sentry.setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
|
||||
if (window.location.pathname.includes("if/")) {
|
||||
Sentry.setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
|
||||
Sentry.configureScope((scope) =>
|
||||
scope.setTransactionName(`authentik.web.if.${currentInterface()}`),
|
||||
);
|
||||
}
|
||||
if (cfg.errorReporting.sendPii && canDoPpi) {
|
||||
me().then((user) => {
|
||||
Sentry.setUser({ email: user.user.email });
|
||||
console.debug("authentik/config: Sentry with PII enabled.");
|
||||
});
|
||||
} else {
|
||||
console.debug("authentik/config: Sentry enabled.");
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Get the interface name from URL
|
||||
export function currentInterface(): string {
|
||||
const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//);
|
||||
let currentInterface = "unknown";
|
||||
if (pathMatches && pathMatches.length >= 2) {
|
||||
currentInterface = pathMatches[1];
|
||||
}
|
||||
return currentInterface;
|
||||
}
|
416
web/src/common/styles/authentik.css
Normal file
416
web/src/common/styles/authentik.css
Normal file
@ -0,0 +1,416 @@
|
||||
:root {
|
||||
--ak-accent: #fd4b2d;
|
||||
|
||||
--ak-dark-foreground: #fafafa;
|
||||
--ak-dark-foreground-darker: #bebebe;
|
||||
--ak-dark-foreground-link: #5a5cb9;
|
||||
--ak-dark-background: #18191a;
|
||||
--ak-dark-background-darker: #000000;
|
||||
--ak-dark-background-light: #1c1e21;
|
||||
--ak-dark-background-light-ish: #212427;
|
||||
--ak-dark-background-lighter: #2b2e33;
|
||||
|
||||
--pf-c-background-image--BackgroundImage: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--sm-2x: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--lg: var(--ak-flow-background);
|
||||
}
|
||||
|
||||
html {
|
||||
--pf-c-nav__link--PaddingTop: 0.5rem;
|
||||
--pf-c-nav__link--PaddingRight: 0.5rem;
|
||||
--pf-c-nav__link--PaddingBottom: 0.5rem;
|
||||
--pf-c-nav__link--PaddingLeft: 0.5rem;
|
||||
}
|
||||
|
||||
html > form > input {
|
||||
position: absolute;
|
||||
top: -2000px;
|
||||
left: -2000px;
|
||||
}
|
||||
|
||||
.pf-c-page__header {
|
||||
z-index: 0;
|
||||
background-color: var(--ak-dark-background-light);
|
||||
box-shadow: var(--pf-global--BoxShadow--lg-bottom);
|
||||
}
|
||||
|
||||
/*****************************
|
||||
* Login adjustments
|
||||
*****************************/
|
||||
/* Ensure card is displayed on small screens */
|
||||
.pf-c-login__main {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.ak-login-container {
|
||||
height: calc(100vh - var(--pf-global--spacer--lg) - var(--pf-global--spacer--lg));
|
||||
width: 35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
.pf-c-login__header {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.pf-c-login__footer {
|
||||
flex-grow: 2;
|
||||
}
|
||||
.pf-c-login__footer ul.pf-c-list.pf-m-inline {
|
||||
justify-content: center;
|
||||
}
|
||||
/*****************************
|
||||
* End Login adjustments
|
||||
*****************************/
|
||||
|
||||
.pf-c-content h1 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pf-c-content h1 i {
|
||||
font-style: normal;
|
||||
}
|
||||
.pf-c-content h1 :first-child {
|
||||
margin-right: var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
/* ensure background on non-flow pages match */
|
||||
.pf-c-background-image::before {
|
||||
background-image: var(--ak-flow-background);
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.pf-m-success {
|
||||
color: var(--pf-global--success-color--100) !important;
|
||||
}
|
||||
.pf-m-warning {
|
||||
color: var(--pf-global--warning-color--100);
|
||||
}
|
||||
.pf-m-danger {
|
||||
color: var(--pf-global--danger-color--100);
|
||||
}
|
||||
|
||||
.form-help-text {
|
||||
color: var(--pf-global--Color--100);
|
||||
}
|
||||
|
||||
/* Fix alignment issues with images in tables */
|
||||
.pf-c-table tbody > tr > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pf-c-description-list__description .pf-c-button {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ak-static-page h1 {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
body {
|
||||
background-color: var(--ak-dark-background) !important;
|
||||
}
|
||||
:root {
|
||||
--pf-global--Color--100: var(--ak-dark-foreground);
|
||||
--pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker);
|
||||
--pf-global--link--Color: var(--ak-dark-foreground-link);
|
||||
--pf-c-radio__label--Color: var(--ak-dark-foreground-link);
|
||||
}
|
||||
/* Global page background colour */
|
||||
.pf-c-page {
|
||||
--pf-c-page--BackgroundColor: var(--ak-dark-background);
|
||||
}
|
||||
.pf-c-drawer__content {
|
||||
--pf-c-drawer__content--BackgroundColor: var(--ak-dark-background);
|
||||
}
|
||||
.pf-c-title {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-u-mb-xl {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* Header sections */
|
||||
.pf-c-page__main-section {
|
||||
--pf-c-page__main-section--BackgroundColor: var(--ak-dark-background);
|
||||
}
|
||||
.sidebar-trigger,
|
||||
.notification-trigger {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.pf-c-page__main-section.pf-m-light {
|
||||
background-color: transparent;
|
||||
}
|
||||
.pf-c-content {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* Card */
|
||||
.pf-c-card {
|
||||
--pf-c-card--BackgroundColor: var(--ak-dark-background-light);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-card__title,
|
||||
.pf-c-card__body {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-toolbar {
|
||||
--pf-c-toolbar--BackgroundColor: var(--ak-dark-background-light);
|
||||
}
|
||||
.pf-c-pagination.pf-m-bottom {
|
||||
background-color: var(--ak-dark-background-light);
|
||||
}
|
||||
/* table */
|
||||
.pf-c-table {
|
||||
--pf-c-table--BackgroundColor: var(--ak-dark-background-light);
|
||||
--pf-c-table--BorderColor: var(--ak-dark-background-lighter);
|
||||
--pf-c-table--cell--Color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-table__text {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-table__sort:not(.pf-m-selected) .pf-c-table__button .pf-c-table__text {
|
||||
color: var(--ak-dark-foreground) !important;
|
||||
}
|
||||
.pf-c-table__sort-indicator i {
|
||||
color: var(--ak-dark-foreground) !important;
|
||||
}
|
||||
.pf-c-table__expandable-row.pf-m-expanded {
|
||||
--pf-c-table__expandable-row--m-expanded--BorderBottomColor: var(
|
||||
--ak-dark-background-lighter
|
||||
);
|
||||
}
|
||||
/* tabs */
|
||||
.pf-c-tabs {
|
||||
background-color: transparent;
|
||||
}
|
||||
.pf-c-tabs.pf-m-box.pf-m-vertical .pf-c-tabs__list::before {
|
||||
border-color: transparent;
|
||||
}
|
||||
.pf-c-tabs.pf-m-box .pf-c-tabs__item.pf-m-current:first-child .pf-c-tabs__link::before {
|
||||
border-color: transparent;
|
||||
}
|
||||
.pf-c-tabs__link::before {
|
||||
border-color: transparent;
|
||||
}
|
||||
.pf-c-tabs__item.pf-m-current {
|
||||
--pf-c-tabs__link--after--BorderColor: #fd4b2d;
|
||||
}
|
||||
.pf-c-tabs.pf-m-vertical .pf-c-tabs__link {
|
||||
background-color: transparent;
|
||||
}
|
||||
/* table, on mobile */
|
||||
@media screen and (max-width: 1200px) {
|
||||
.pf-m-grid-xl.pf-c-table tbody:first-of-type {
|
||||
border-top-color: var(--ak-dark-background);
|
||||
}
|
||||
.pf-m-grid-xl.pf-c-table tr:not(.pf-c-table__expandable-row) {
|
||||
border-bottom-color: var(--ak-dark-background);
|
||||
}
|
||||
}
|
||||
/* class for pagination text */
|
||||
.pf-c-options-menu__toggle {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* table icon used for expanding rows */
|
||||
.pf-c-table__toggle-icon {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* expandable elements */
|
||||
.pf-c-expandable-section__toggle-text {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-expandable-section__toggle-icon {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* header for form group */
|
||||
.pf-c-form__field-group-header-title-text {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-form__field-group {
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* inputs */
|
||||
optgroup,
|
||||
option {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
select[multiple] optgroup:checked,
|
||||
select[multiple] option:checked {
|
||||
color: var(--ak-dark-background);
|
||||
}
|
||||
.pf-c-input-group {
|
||||
--pf-c-input-group--BackgroundColor: transparent;
|
||||
}
|
||||
.pf-c-form-control {
|
||||
--pf-c-form-control--BorderTopColor: var(--ak-dark-background-lighter);
|
||||
--pf-c-form-control--BorderRightColor: var(--ak-dark-background-lighter);
|
||||
--pf-c-form-control--BorderLeftColor: var(--ak-dark-background-lighter);
|
||||
--pf-global--BackgroundColor--100: transparent;
|
||||
--pf-c-form-control--BackgroundColor: var(--ak-dark-background-light);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-form-control:disabled {
|
||||
background-color: var(--ak-dark-background-light);
|
||||
}
|
||||
.pf-c-form-control[readonly] {
|
||||
background-color: var(--ak-dark-background-light);
|
||||
}
|
||||
/* select toggle */
|
||||
.pf-c-select__toggle::before {
|
||||
--pf-c-select__toggle--before--BorderTopColor: var(--ak-dark-background-lighter);
|
||||
--pf-c-select__toggle--before--BorderRightColor: var(--ak-dark-background-lighter);
|
||||
--pf-c-select__toggle--before--BorderLeftColor: var(--ak-dark-background-lighter);
|
||||
}
|
||||
.pf-c-select__toggle.pf-m-typeahead {
|
||||
--pf-c-select__toggle--BackgroundColor: var(--ak-dark-background-light);
|
||||
}
|
||||
.pf-c-select__menu {
|
||||
--pf-c-select__menu--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-select__menu-item {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-select__menu-wrapper:hover,
|
||||
.pf-c-select__menu-item:hover {
|
||||
--pf-c-select__menu-item--hover--BackgroundColor: var(--ak-dark-background-lighter);
|
||||
}
|
||||
.pf-c-select__menu-wrapper:focus-within,
|
||||
.pf-c-select__menu-wrapper.pf-m-focus,
|
||||
.pf-c-select__menu-item:focus,
|
||||
.pf-c-select__menu-item.pf-m-focus {
|
||||
--pf-c-select__menu-item--focus--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
.pf-c-button.pf-m-plain:hover {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-button.pf-m-control {
|
||||
--pf-c-button--after--BorderColor: var(--ak-dark-background-lighter)
|
||||
var(--ak-dark-background-lighter)
|
||||
var(--pf-c-button--m-control--after--BorderBottomColor)
|
||||
var(--ak-dark-background-lighter);
|
||||
background-color: var(--ak-dark-background-light);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-m-tertiary,
|
||||
.pf-c-button.pf-m-tertiary {
|
||||
--pf-c-button--after--BorderColor: var(--ak-dark-foreground-darker);
|
||||
color: var(--ak-dark-foreground-darker);
|
||||
}
|
||||
.pf-m-tertiary:hover,
|
||||
.pf-c-button.pf-m-tertiary:hover {
|
||||
--pf-c-button--after--BorderColor: var(--ak-dark-background-lighter);
|
||||
}
|
||||
.pf-c-form__label-text {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-check__label {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.form-help-text {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-dropdown__toggle::before {
|
||||
border-color: transparent;
|
||||
}
|
||||
.pf-c-toggle-group__button {
|
||||
color: var(--ak-dark-foreground) !important;
|
||||
}
|
||||
.pf-c-toggle-group__button:not(.pf-m-selected) {
|
||||
background-color: var(--ak-dark-background-light) !important;
|
||||
}
|
||||
.pf-c-toggle-group__button.pf-m-selected {
|
||||
color: var(--ak-dark-foreground) !important;
|
||||
background-color: var(--pf-global--primary-color--100) !important;
|
||||
}
|
||||
/* inputs help text */
|
||||
.pf-c-form__helper-text:not(.pf-m-error) {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* modal */
|
||||
.pf-c-modal-box,
|
||||
.pf-c-modal-box__header,
|
||||
.pf-c-modal-box__footer,
|
||||
.pf-c-modal-box__body {
|
||||
background-color: var(--ak-dark-background);
|
||||
}
|
||||
/* sidebar */
|
||||
.pf-c-nav {
|
||||
background-color: var(--ak-dark-background-light);
|
||||
}
|
||||
/* flows */
|
||||
.pf-c-login__main {
|
||||
background-color: var(--ak-dark-background);
|
||||
}
|
||||
.pf-c-login__main-body,
|
||||
.pf-c-login__main-header,
|
||||
.pf-c-login__main-header-desc {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-login__main-footer-links-item img {
|
||||
filter: invert(1);
|
||||
}
|
||||
.pf-c-login__main-footer-band {
|
||||
background-color: var(--ak-dark-background-lighter);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.form-control-static {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* notifications */
|
||||
.pf-c-drawer__panel {
|
||||
background-color: var(--ak-dark-background);
|
||||
}
|
||||
.pf-c-notification-drawer {
|
||||
--pf-c-notification-drawer--BackgroundColor: var(--ak-dark-background);
|
||||
}
|
||||
.pf-c-notification-drawer__header {
|
||||
background-color: var(--ak-dark-background-lighter);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
.pf-c-notification-drawer__list-item {
|
||||
background-color: var(--ak-dark-background-light-ish);
|
||||
color: var(--ak-dark-foreground);
|
||||
--pf-c-notification-drawer__list-item--BorderBottomColor: var(
|
||||
--ak-dark-background-lighter
|
||||
) !important;
|
||||
}
|
||||
/* data list */
|
||||
.pf-c-data-list {
|
||||
border-top-color: var(--ak-dark-background-lighter);
|
||||
}
|
||||
.pf-c-data-list__item {
|
||||
--pf-c-data-list__item--BackgroundColor: transparent;
|
||||
--pf-c-data-list__item--BorderBottomColor: var(--ak-dark-background-lighter);
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* wizards */
|
||||
.pf-c-wizard__nav {
|
||||
--pf-c-wizard__nav--BackgroundColor: var(--ak-dark-background-lighter);
|
||||
--pf-c-wizard__nav--lg--BorderRightColor: transparent;
|
||||
}
|
||||
.pf-c-wizard__main {
|
||||
background-color: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
.pf-c-wizard__footer {
|
||||
--pf-c-wizard__footer--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
.pf-c-wizard__toggle-num,
|
||||
.pf-c-wizard__nav-link::before {
|
||||
--pf-c-wizard__nav-link--before--BackgroundColor: transparent;
|
||||
}
|
||||
/* tree view */
|
||||
.pf-c-tree-view__node:focus {
|
||||
--pf-c-tree-view__node--focus--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
.pf-c-tree-view__content:hover,
|
||||
.pf-c-tree-view__content:focus-within {
|
||||
--pf-c-tree-view__node--hover--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-data-list__item {
|
||||
background-color: transparent;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { me } from "@goauthentik/web/api/Users";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
|
||||
import { UserSelf } from "@goauthentik/api";
|
||||
|
174
web/src/common/ui/locale.ts
Normal file
174
web/src/common/ui/locale.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
|
||||
import { Messages, i18n } from "@lingui/core";
|
||||
import { detect, fromNavigator, fromUrl } from "@lingui/detect-locale";
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
interface Locale {
|
||||
locale: Messages;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
plurals: Function;
|
||||
}
|
||||
|
||||
export const LOCALES: {
|
||||
code: string;
|
||||
label: string;
|
||||
locale: () => Promise<Locale>;
|
||||
}[] = [
|
||||
{
|
||||
code: "en",
|
||||
label: t`English`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/en")).messages,
|
||||
plurals: (await import("make-plural/plurals")).en,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "debug",
|
||||
label: t`Debug`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/pseudo-LOCALE")).messages,
|
||||
plurals: (await import("make-plural/plurals")).en,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "fr",
|
||||
label: t`French`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/fr_FR")).messages,
|
||||
plurals: (await import("make-plural/plurals")).fr,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "tr",
|
||||
label: t`Turkish`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/tr")).messages,
|
||||
plurals: (await import("make-plural/plurals")).tr,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es",
|
||||
label: t`Spanish`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/es")).messages,
|
||||
plurals: (await import("make-plural/plurals")).es,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pl",
|
||||
label: t`Polish`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/pl")).messages,
|
||||
plurals: (await import("make-plural/plurals")).pl,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh_TW",
|
||||
label: t`Taiwanese Mandarin`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/zh_TW")).messages,
|
||||
plurals: (await import("make-plural/plurals")).zh,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-CN",
|
||||
label: t`Chinese (simplified)`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/zh-Hans")).messages,
|
||||
plurals: (await import("make-plural/plurals")).zh,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-HK",
|
||||
label: t`Chinese (traditional)`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/zh-Hant")).messages,
|
||||
plurals: (await import("make-plural/plurals")).zh,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "de",
|
||||
label: t`German`,
|
||||
locale: async () => {
|
||||
return {
|
||||
locale: (await import("@goauthentik/locales/de")).messages,
|
||||
plurals: (await import("make-plural/plurals")).de,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_FALLBACK = () => "en";
|
||||
|
||||
export function autoDetectLanguage() {
|
||||
const detected =
|
||||
detect(
|
||||
() => {
|
||||
return globalAK()?.locale;
|
||||
},
|
||||
fromUrl("locale"),
|
||||
fromNavigator(),
|
||||
DEFAULT_FALLBACK,
|
||||
) || DEFAULT_FALLBACK();
|
||||
const locales = [detected];
|
||||
// For now we only care about the first locale part
|
||||
if (detected.includes("_")) {
|
||||
locales.push(detected.split("_")[0]);
|
||||
}
|
||||
if (detected.includes("-")) {
|
||||
locales.push(detected.split("-")[0]);
|
||||
}
|
||||
for (const tryLocale of locales) {
|
||||
if (LOCALES.find((locale) => locale.code === tryLocale)) {
|
||||
console.debug(`authentik/locale: Activating detected locale '${tryLocale}'`);
|
||||
activateLocale(tryLocale);
|
||||
return;
|
||||
} else {
|
||||
console.debug(`authentik/locale: No matching locale for ${tryLocale}`);
|
||||
}
|
||||
}
|
||||
console.debug(`authentik/locale: No locale for '${locales}', falling back to en`);
|
||||
activateLocale(DEFAULT_FALLBACK());
|
||||
}
|
||||
export function activateLocale(code: string) {
|
||||
const urlLocale = fromUrl("locale");
|
||||
if (urlLocale !== null && urlLocale !== "") {
|
||||
code = urlLocale;
|
||||
}
|
||||
const locale = LOCALES.find((locale) => locale.code == code);
|
||||
if (!locale) {
|
||||
console.warn(`authentik/locale: failed to find locale for code ${code}`);
|
||||
return;
|
||||
}
|
||||
locale.locale().then((localeData) => {
|
||||
i18n.loadLocaleData(locale.code, { plurals: localeData.plurals });
|
||||
i18n.load(locale.code, localeData.locale);
|
||||
i18n.activate(locale.code);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EVENT_LOCALE_CHANGE, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
56
web/src/common/users.ts
Normal file
56
web/src/common/users.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { activateLocale } from "@goauthentik/common/ui/locale";
|
||||
|
||||
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
|
||||
|
||||
let globalMePromise: Promise<SessionUser> | undefined;
|
||||
|
||||
export function refreshMe(): Promise<SessionUser> {
|
||||
globalMePromise = undefined;
|
||||
return me();
|
||||
}
|
||||
|
||||
export function me(): Promise<SessionUser> {
|
||||
if (!globalMePromise) {
|
||||
globalMePromise = new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersMeRetrieve()
|
||||
.then((user) => {
|
||||
if (!user.user.settings || !("locale" in user.user.settings)) {
|
||||
return user;
|
||||
}
|
||||
const locale = user.user.settings.locale;
|
||||
if (locale && locale !== "") {
|
||||
console.debug(
|
||||
`authentik/locale: Activating user's configured locale '${locale}'`,
|
||||
);
|
||||
activateLocale(locale);
|
||||
}
|
||||
return user;
|
||||
})
|
||||
.catch((ex: ResponseError) => {
|
||||
const defaultUser: SessionUser = {
|
||||
user: {
|
||||
pk: -1,
|
||||
isSuperuser: false,
|
||||
isActive: true,
|
||||
groups: [],
|
||||
avatar: "",
|
||||
uid: "",
|
||||
username: "",
|
||||
name: "",
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
if (ex.response.status === 401 || ex.response.status === 403) {
|
||||
const relativeUrl = window.location
|
||||
.toString()
|
||||
.substring(window.location.origin.length);
|
||||
window.location.assign(
|
||||
`/flows/-/default/authentication/?next=${encodeURIComponent(relativeUrl)}`,
|
||||
);
|
||||
}
|
||||
return defaultUser;
|
||||
});
|
||||
}
|
||||
return globalMePromise;
|
||||
}
|
94
web/src/common/utils.ts
Normal file
94
web/src/common/utils.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
|
||||
export function getCookie(name: string): string {
|
||||
let cookieValue = "";
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
export function convertToSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]+/g, "");
|
||||
}
|
||||
|
||||
export function convertToTitle(text: string): string {
|
||||
return text.replace(/\w\S*/g, function (txt) {
|
||||
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
export function truncate(input?: string, max = 10): string {
|
||||
input = input || "";
|
||||
const array = input.trim().split(" ");
|
||||
const ellipsis = array.length > max ? "..." : "";
|
||||
|
||||
return array.slice(0, max).join(" ") + ellipsis;
|
||||
}
|
||||
|
||||
export function camelToSnake(key: string): string {
|
||||
const result = key.replace(/([A-Z])/g, " $1");
|
||||
return result.split(" ").join("_").toLowerCase();
|
||||
}
|
||||
|
||||
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
|
||||
const m = new Map<string, T[]>();
|
||||
objects.forEach((obj) => {
|
||||
const group = callback(obj);
|
||||
if (!m.has(group)) {
|
||||
m.set(group, []);
|
||||
}
|
||||
const tProviders = m.get(group) || [];
|
||||
tProviders.push(obj);
|
||||
});
|
||||
return Array.from(m).sort();
|
||||
}
|
||||
|
||||
export function first<T>(...args: Array<T | undefined | null>): T {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const element = args[index];
|
||||
if (element !== undefined && element !== null) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
throw new SentryIgnoredError(`No compatible arg given: ${args}`);
|
||||
}
|
||||
|
||||
export function hexEncode(buf: Uint8Array): string {
|
||||
return Array.from(buf)
|
||||
.map(function (x) {
|
||||
return ("0" + x.toString(16)).substr(-2);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function randomString(len: number): string {
|
||||
const arr = new Uint8Array(len / 2);
|
||||
window.crypto.getRandomValues(arr);
|
||||
return hexEncode(arr);
|
||||
}
|
||||
|
||||
export function dateTimeLocal(date: Date): string {
|
||||
// So for some reason, the datetime-local input field requires ISO Datetime as value
|
||||
// But the standard javascript date.toISOString() returns everything with seconds and
|
||||
// milliseconds, which the input field doesn't like (on chrome, on firefox its fine)
|
||||
// On chrome, setting .valueAsNumber works, but that causes an error on firefox, so go
|
||||
// figure.
|
||||
// Additionally, toISOString always returns the date without timezone, which we would like
|
||||
// to include for better usability
|
||||
const tzOffset = new Date().getTimezoneOffset() * 60000; //offset in milliseconds
|
||||
const localISOTime = new Date(date.getTime() - tzOffset).toISOString().slice(0, -1);
|
||||
const parts = localISOTime.split(":");
|
||||
return `${parts[0]}:${parts[1]}`;
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { EVENT_WS_MESSAGE } from "@goauthentik/web/constants";
|
||||
import { MessageLevel } from "@goauthentik/web/elements/messages/Message";
|
||||
import { showMessage } from "@goauthentik/web/elements/messages/MessageContainer";
|
||||
import { EVENT_MESSAGE, EVENT_WS_MESSAGE } from "@goauthentik/common/constants";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
@ -33,12 +32,15 @@ export class WebsocketClient {
|
||||
this.messageSocket.addEventListener("close", (e) => {
|
||||
console.debug(`authentik/ws: closed ws connection: ${e}`);
|
||||
if (this.retryDelay > 6000) {
|
||||
showMessage(
|
||||
{
|
||||
level: MessageLevel.error,
|
||||
message: t`Connection error, reconnecting...`,
|
||||
},
|
||||
true,
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EVENT_MESSAGE, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
level: MessageLevel.error,
|
||||
message: t`Connection error, reconnecting...`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
setTimeout(() => {
|
||||
|
Reference in New Issue
Block a user