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:
Jens L
2022-09-15 00:05:21 +02:00
committed by GitHub
parent 369440652c
commit 4a91a7d2e2
291 changed files with 2062 additions and 1921 deletions

View 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}`);

View 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);
}
}

View 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";

View File

@ -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
View 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
View 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;
}

View 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;
});
}
}

View 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,
},
};
}

View File

@ -0,0 +1,6 @@
export enum MessageLevel {
error = "error",
warning = "warning",
success = "success",
info = "info",
}

83
web/src/common/sentry.ts Normal file
View 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;
}

View 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;
}

View File

@ -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
View 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
View 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
View 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]}`;
}

View File

@ -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(() => {